209 Commits

Author SHA1 Message Date
hsiegeln
cd92036f91 ci(minter): deploy license-minter JARs to Gitea Maven registry on push
Some checks failed
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 4m57s
CI / cleanup-branch (pull_request) Has been skipped
CI / build (pull_request) Successful in 4m35s
CI / docker (pull_request) Has been skipped
CI / docker (push) Successful in 3m44s
CI / deploy (pull_request) Has been skipped
CI / deploy-feature (pull_request) Has been skipped
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Failing after 2m29s
Adds <distributionManagement> at the parent POM and a push-only deploy
step in the build job. Selects the parent + core + minter via -pl so
both the plain library JAR and the Spring Boot fat CLI JAR are pushed
with their full dep tree resolvable; server-app is excluded as a
fat-jar runtime, not a library.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 18:59:38 +02:00
hsiegeln
2f7c6aa005 fix(auth): @NotNull on AuthCapabilitiesResponse.Oidc.providerName 2026-04-26 18:59:20 +02:00
hsiegeln
f945d10d48 feat(auth): AuthCapabilitiesResponse DTO 2026-04-26 18:57:09 +02:00
hsiegeln
ddb18c4f17 feat(auth): OidcProviderNameDeriver — issuer URI → display label
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 18:53:31 +02:00
hsiegeln
f1aa1ea19f docs(auth): implementation plan for login routing harmonization
9 tasks, TDD throughout. Backend: OidcProviderNameDeriver utility,
AuthCapabilitiesResponse DTO, AuthCapabilitiesController. Frontend:
useAuthCapabilities hook, capability-driven LoginPage rewrite,
OidcCallback ?local trap removal. Plus docs and manual smoke for
the original SaaS-provisioned tenant bug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 18:46:55 +02:00
hsiegeln
a3c0e9aa7f docs(auth): harmonization design — login routing capability model
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 2m37s
CI / docker (push) Successful in 2m32s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 53s
Captures the decision to gate login UX on capabilities (no SaaS-mode
flag), drop prompt=none from the primary OIDC flow per RFC 9700 §4.4,
and keep ?local as the explicit admin-recovery escape hatch.

MFA enrollment / enforcement and password reset for local accounts are
explicitly deferred and tracked in issue #154.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 18:37:00 +02:00
hsiegeln
5216dab043 Merge feature/runtime-hardening: license enforcement (36 tasks)
Delivers a signed-token license tier system with 8 enforced cap
surfaces (envs/apps/agents/users/outbound/alert-rules/compute/jar-
retention), per-tenant validation, daily revalidation, ClickHouse
TTL recompute on license change, audit trail, and Prometheus
metrics. Plus runtime container-hardening pre-work and a stand-
alone cameleer-license-minter Maven module (test-scope only on
the server).

40 license commits + 2 prior runtime-hardening commits +
3 design/spec/plan + 3 docs (minter README, operator guide,
SaaS handoff).

Range: ec51aef8..5864553f
Tasks: 1-36 of docs/superpowers/plans/2026-04-25-license-enforcement.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 16:33:44 +02:00
hsiegeln
5864553fed docs(license): minter README + operator guide + SaaS handoff
cameleer-license-minter/README.md — vendor-side guide: build, public
LicenseMinter API, CLI usage with all flags, token format (standard
base64, not url-safe), LicenseInfo schema, Ed25519 key generation,
worked example, security guidance, runtime-separation verification.

docs/license-enforcement.md — operator guide: install paths and
priority (env > file > DB > none), public-key config, REST API,
state machine (ABSENT/ACTIVE/GRACE/EXPIRED/INVALID), default tier
caps, 403 envelope semantics, retention TTL recompute, daily
revalidation, audit + Prometheus surfaces, troubleshooting.

docs/handoff/2026-04-26-license-saas-handoff.md — SaaS playbook:
trust model, onboarding/renewal/revocation runbooks, key management,
cap matrix per plan tier, telemetry, failure modes, testing guidance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 16:33:12 +02:00
hsiegeln
140ea88460 docs(rules): document license enforcement classes + endpoints
Final consolidation pass after the 36-task license-enforcement work.

core-classes.md:
- New license/ section: LicenseInfo, LicenseLimits, DefaultTierLimits,
  LicenseValidator, LicenseGate, LicenseStateMachine, LicenseState.
- runtime/: added CreateGuard (functional interface for license-cap
  hooks consulted by EnvironmentService/AppService/AgentRegistryService).
- admin/: AuditCategory.LICENSE added to the documented enum value list.

app-classes.md:
- New license/ section: LicenseService, LicenseRepository, LicenseRecord,
  PostgresLicenseRepository, LicenseChangedEvent, LicenseEnforcer,
  LicenseUsageReader, LicenseCapExceededException, LicenseExceptionAdvice,
  LicenseMessageRenderer, RetentionPolicyApplier, LicenseRevalidationJob,
  LicenseMetrics.
- LicenseAdminController entry expanded to document the GET response
  shape and the LicenseService.install delegation pattern.
- config/: RuntimeBeanConfig note about CreateGuard wiring; new
  LicenseBeanConfig entry covering the four-bean topology and the
  always-failing-validator fallback.

Note: LicenseChangedEvent, LicenseRepository, LicenseRecord, and
PostgresLicenseRepository live in cameleer-server-app, not -core; the
plan's section assignments were corrected against the actual code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 16:17:31 +02:00
hsiegeln
581dc1ad13 test(license): SchemaBootstrapIT — assert V5 license + retention columns
Two new assertions: license table has tenant_id/license_id/token/
installed_at/installed_by/expires_at/last_validated_at columns with
expected types + NOT NULL constraints, PK on tenant_id; environments
has execution_retention_days/log_retention_days/metric_retention_days
all integer NOT NULL DEFAULT 1.

Note: V5 migration does not include an installed_via column; the
plan's spec was aspirational. Test asserts what the migration
actually creates (and what PostgresLicenseRepository reads/writes).

OpenAPI regen (Step 35.2) deferred to session end — requires running
backend + UI dev server.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 16:13:50 +02:00
hsiegeln
e198c13e8a test(license): RetentionRuntimeRecomputeIT — TTL recompute on license change
Install license with max_log_retention_days=30, env.configured=60 →
effective=30; verify ClickHouse logs table reflects toIntervalDay(30).
Replace with max=7 → effective=7; verify TTL recomputed. Polls
system.tables.create_table_query up to 5s for the @Async listener
to apply.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 16:08:28 +02:00
hsiegeln
1e78439ddd test(license): LicenseEnforcementIT — cross-cap smoke regression net
Five @Nested cap surfaces (envs, apps, outbound, alert rules, users)
share a single synthetic license with cap=1 each. Each test pushes
just past the cap and verifies the standard 403 envelope plus a
cap_exceeded audit row. Per-limit ITs cover full per-cap behavior;
this IT catches accidental wire-rip regressions across all caps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 16:00:50 +02:00
hsiegeln
1a307da6b2 test(license): LicenseLifecycleIT — install/persist/revalidate/reject
End-to-end IT covering the full lifecycle: mint a token via
cameleer-license-minter (test-scope), POST it via /api/v1/admin/license,
verify state=ACTIVE, clear gate, revalidate from PG, verify state restored.
Plus: tampered signature -> 400 + LICENSE/FAILURE audit row, gate not
mutated to ACTIVE.

Adds cameleer-license-minter as a test-scope dep on cameleer-server-app
(verified absent from runtime/compile classpaths). Also disables the
default spring-boot:repackage execution on the minter pom so the main
artifact stays as a plain library JAR consumable as a Maven dependency
(the cli classifier still produces the executable jar).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:56:01 +02:00
hsiegeln
885f2be16b feat(license): Prometheus gauges for state + days remaining
cameleer_license_state{state=...} (one-hot per LicenseState),
cameleer_license_days_remaining (negative when ABSENT/INVALID),
cameleer_license_last_validated_age_seconds. Refreshed on
LicenseChangedEvent and every 60s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:43:54 +02:00
hsiegeln
945ecd78cf feat(license): LicenseUsageController GET /api/v1/admin/license/usage
Returns state, expiresAt/daysRemaining, lastValidatedAt, message
(LicenseMessageRenderer.forState), and a limits[] array where each
entry carries key/current/cap/source ("license" vs "default"). Adds
public AgentRegistryService.liveCount() so max_agents can be reported
from the in-memory registry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:42:39 +02:00
hsiegeln
3f69e546e4 refactor(license): LicenseAdminController delegates to LicenseService
GET returns {state, invalidReason, envelope, lastValidatedAt}. POST
delegates to licenseService.install(token, userId, "api") so install
goes through audit + persistence + event publish. Removes the inline
LicenseValidator construction from the controller.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:34:07 +02:00
hsiegeln
340d954fed feat(license): LicenseRevalidationJob — daily cron + 60s post-startup
@Scheduled(cron = "0 0 3 * * *") triggers svc.revalidate() daily.
@EventListener(ApplicationReadyEvent.class) @Async fires once 60s
after boot to catch ABSENT->ACTIVE transitions if the license was
written to PG between server starts. Exceptions are logged but never
propagate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:32:33 +02:00
hsiegeln
484a55f4f4 feat(license): RetentionPolicyApplier listens on LicenseChangedEvent
@EventListener fires on every license install/replace/expire. For each
environment, computes effective TTL = min(licenseCap, env.configured)
and emits one ALTER TABLE ... MODIFY TTL ... per (table, env). Tables
covered: executions, processor_executions, logs, agent_metrics,
agent_events. ClickHouse failures are logged but do not propagate
(listener is async-tolerant).

route_diagrams is intentionally excluded -- it has no TTL clause in
init.sql (ReplacingMergeTree keyed on content_hash, not time-series).
server_metrics is also excluded -- it has no environment column
(server straddles environments).

Per-environment TTL via WHERE requires ClickHouse 22.3+; the project's
current image (clickhouse/clickhouse-server:24.12) is well above that
floor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:28:42 +02:00
hsiegeln
cc5d88d708 feat(license): surface execution/log/metric retention days on Environment
Adds three int fields to the Environment record + repository row mapper,
matching the columns added in V5. Default value is 1 per the V5 NOT NULL
DEFAULT 1. Read DTO surfaces the fields via Jackson record serialization;
setter endpoint deferred to a follow-up that wires the corresponding
license cap checks.

The canonical constructor enforces >= 1 for each retention field — V5
guarantees this at the DB level, but the runtime guard catches in-memory
construction errors (e.g., test sites that pass 0).

Test sites updated to the 12-arg signature with retention defaults of 1.
EnvironmentAdminControllerIT gains a regression test asserting the wire
shape exposes all three fields.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:22:40 +02:00
hsiegeln
046f08fe87 feat(license): enforce max_jar_retention_count at PUT jar-retention
Returns 422 UNPROCESSABLE_ENTITY when jarRetentionCount exceeds
license cap. Default tier cap = 3. The other three retention caps
(execution/log/metric retention days) are deferred to T26+ where
the corresponding fields are added to Environment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:16:04 +02:00
hsiegeln
56bddcc747 feat(license): enforce compute caps at DeploymentExecutor PRE_FLIGHT
Adds ComputeUsage record + computeUsage() helper to LicenseUsageReader
that aggregates from PG. DeploymentExecutor.executeAsync runs three
assertWithinCap checks (max_total_cpu_millis, max_total_memory_mb,
max_total_replicas) right after config resolution. The existing
executor try/catch turns a LicenseCapExceededException into a FAILED
deployment with the cap message in the failure reason.

Adds ComputeCapEnforcementIT (HTTP-driven; @MockBean RuntimeOrchestrator,
since cap rejection short-circuits before any orchestrator call) plus
defensive license lifts in BlueGreenStrategyIT, RollingStrategyIT,
DeploymentSnapshotIT, and DeploymentControllerAuditIT so sequential
deploys under testcontainer reuse don't trip the new caps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:09:39 +02:00
hsiegeln
71f3b70b86 feat(license): enforce max_alert_rules at AlertRuleController.create
Adds AlertRuleRepository.count() and a LicenseEnforcer.assertWithinCap
call at the top of the POST handler. Default cap = 2; the 3rd rule
gets the standard 403 envelope. Sibling alert ITs that legitimately
need more than 2 rules get the cap lifted via the test-license helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 14:50:59 +02:00
hsiegeln
5a579415a1 feat(license): enforce max_outbound_connections at OutboundConnectionServiceImpl.create
Adds LicenseEnforcer.assertWithinCap call at the top of create() using
repo.listByTenant(tenantId).size() as the current count. Lifts the cap
in OutboundConnectionAdminControllerIT (duplicateNameReturns409 needs
2 creates in one test). LicenseExceptionAdvice maps the rejection to
the standard 403 envelope; cap_exceeded audit row emitted via the
LicenseEnforcer 3-arg ctor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 14:40:12 +02:00
hsiegeln
1ff30905f7 feat(license): enforce max_users at user creation paths
Wires LicenseEnforcer into UserAdminController.createUser and
OidcAuthController auto-signup. Cap fires before any validation so
over-cap creates short-circuit cheaply. Audit emission already
present (LicenseEnforcer 3-arg ctor from T16 emits cap_exceeded
under AuditCategory.LICENSE).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 14:29:54 +02:00
hsiegeln
afdaee628b feat(license): enforce max_agents at AgentRegistryService.register
Adds a CreateGuard to AgentRegistryService that fires only on NEW
registrations: re-registers of an existing agent bypass the cap (they
don't grow the registry, and rejecting them would orphan an agent that
already counts against the cap). Live-only count for cap enforcement —
STALE/DEAD/SHUTDOWN agents are excluded so the cap reflects the working
fleet, not historical residue.

Reuses the CreateGuard pattern from T18-T19. The global
LicenseExceptionAdvice maps the resulting LicenseCapExceededException to
403 with the structured envelope — no AgentRegistrationController
changes needed.

AgentCapEnforcementIT exercises the HTTP path end-to-end: two registers
succeed at cap=2, a third returns 403 with the expected envelope, and a
re-register of an already-registered agent succeeds at-cap.

Sibling agent-registering ITs (Agent*ControllerIT, Diagram*IT,
Execution*IT, Search*IT, Protocol*IT, Backpressure*IT, JwtRefresh*IT,
Registration*IT, Security*IT, SseSigning*IT, IngestionSchemaIT) lift
max_agents in @BeforeEach and clear the synthetic license in @AfterEach
— the in-memory registry is shared across @SpringBootTest reuse
boundaries, so without the lift the default-tier max_agents=5 would be
exhausted by accumulated test residue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 14:19:08 +02:00
hsiegeln
80dafe685b feat(license): enforce max_apps at AppService.createApp
Adds CreateGuard hook to AppService.createApp using the same pattern
as T18 (EnvironmentService). AppRepository.count() added; the bean
wires LicenseEnforcer.assertWithinCap("max_apps", current, 1).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:36:34 +02:00
hsiegeln
198811b752 refactor(license-test): rename installTestLicenseWithCaps -> installSyntheticUnsignedLicense
Makes the signature-bypass loud at every call site since T19-T25 will
copy this pattern 5+ more times. The helper still loads via
LicenseGate.load() directly (no signature check) — the new name
ensures any future caller has to acknowledge that.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:24:58 +02:00
hsiegeln
8a64a9e04c feat(license): enforce max_environments at EnvironmentService.create
Adds CreateGuard functional interface to core (preserves the no-Spring
boundary between core and app) and wires LicenseEnforcer into the
EnvironmentService bean in RuntimeBeanConfig so POST
/api/v1/admin/environments rejects with the structured 403 envelope
(error/limit/cap/state/message) once the cap is reached. Default tier
max_environments=1; the V1 baseline seeds the default env, so the very
next create through the API is rejected unless a license lifts the cap.

Also adds EnvironmentRepository.count() (with PostgresEnvironmentRepository
impl), TestSecurityHelper.installTestLicenseWithCaps(...) so existing ITs
that POST envs keep working, and a defensive cleanup in
LicenseUsageReaderIT/EnvironmentAdminControllerIT to stay
order-independent under Testcontainer reuse (deletes deployments+apps
before envs to avoid FK violations).

Test: EnvironmentCapEnforcementIT (new) drives the rejection path
end-to-end and asserts the 403 body shape produced by
LicenseExceptionAdvice.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 13:16:41 +02:00
hsiegeln
f291d7c24d feat(license): LicenseUsageReader aggregates current usage
One COUNT per entity table; one SUM-grouped query over non-stopped
deployments for compute caps. SQL traverses
deployed_config_snapshot->'containerConfig' (corrected from the
plan's top-level path; the snapshot record nests containerConfig
under that key). agentCount is fed in by the controller since it's
an in-memory registry value, not a DB row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:47:59 +02:00
hsiegeln
9b9b56043c fix(license): explicit @Autowired ctor + tolerate audit failures
Two follow-ups to LicenseEnforcer review:
- Add @Autowired to the 3-arg ctor so Spring picks it unambiguously
  (the 2-arg test ctor is otherwise an equally-greedy candidate).
- Wrap audit.log() in try/catch + log.warn so a degraded audit DB
  cannot mask a cap rejection: callers still see HTTP 403 even when
  audit storage is unhealthy.
- Extract counter name to private static final.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:43:27 +02:00
hsiegeln
4985348827 feat(license): LicenseEnforcer single entry point
assertWithinCap consults LicenseGate.getEffectiveLimits, throws
LicenseCapExceededException on overflow, increments
cameleer_license_cap_rejections_total{limit=...} for telemetry, and
emits an AuditCategory.LICENSE cap_exceeded audit row when an
AuditService is wired (3-arg ctor; the test-only 2-arg ctor passes
null and the audit call short-circuits). Unknown limit keys are
programmer errors (IllegalArgumentException), not 403s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:36:58 +02:00
hsiegeln
e98d790874 fix: always show user badge for logout access
When username is empty (e.g. email-registered OIDC users with no display
name), the badge was hidden entirely, making logout inaccessible. Always
render the badge with fallback text "Account".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 12:31:25 +02:00
hsiegeln
2bad9c3e48 feat(license): cap-exceeded exception + state-aware message renderer
LicenseCapExceededException + @ControllerAdvice mapping to 403 with a
body that includes state, limit, current, cap, and a per-state human
message templated by LicenseMessageRenderer (covers ABSENT/ACTIVE/
GRACE/EXPIRED/INVALID with day counts and reason). Adds the forState()
overload now (used by the /usage endpoint in Task 30) so both surfaces
share identical phrasing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:26:39 +02:00
hsiegeln
6f658b6648 docs(license): session handoff at task 14/36
Resume point for the next session executing the License Enforcement
plan. Captures: 14 done commit SHAs, what works/doesn't end-to-end,
critical plan deviations (AuditService.log API; LicenseInfo.label
not tier; throwaway-keypair fallback validator; ClickHouse TTL WHERE
caveat for T27), batching strategy, and suggested next-task order.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:07:35 +02:00
hsiegeln
b95e80a24a feat(license): wire LicenseService into boot order (env > file > DB)
LicenseBootLoader @PostConstruct calls LicenseService.loadInitial,
which delegates to install() so env-var/file/DB paths share a single
audit + event-publish code path. A missing public key now produces
an always-failing validator (constructed with a throwaway keypair so
the parent ctor accepts it) so loaded tokens route to INVALID
instead of being silently ignored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 11:16:49 +02:00
hsiegeln
6fbcf10ee4 feat(license): LicenseService + LicenseChangedEvent
Single mediation point for token install/replace/revalidate. Audits
under AuditCategory.LICENSE, persists to PG, mutates the LicenseGate,
and publishes LicenseChangedEvent so downstream listeners
(RetentionPolicyApplier, LicenseMetrics) react uniformly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 11:11:48 +02:00
hsiegeln
2f75b2865b feat(license): add AuditCategory.LICENSE
Tasks downstream (LicenseService, LicenseEnforcer) audit under
this category for install_license / replace_license / reject_license
/ revalidate_license / cap_exceeded actions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 11:06:07 +02:00
hsiegeln
2e51deb511 feat(license): PostgresLicenseRepository + LicenseRecord
JdbcTemplate-backed repo; upsert is ON CONFLICT (tenant_id), touch
updates only last_validated_at, delete is provided for future
operator-clear flow (not exposed as REST in v1).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 11:05:35 +02:00
hsiegeln
20aefd5bf6 feat(license): Flyway V5 — license table + environments retention columns
Per-tenant license row stores the signed token, licenseId for audit,
installed/expires/last_validated timestamps. environments gains three
INTEGER NOT NULL DEFAULT 1 retention columns (execution, log, metric)
so existing rows land inside the default-tier cap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 11:02:44 +02:00
hsiegeln
f6657f811b feat(license-minter): --verify round-trips before shipping
Adds --verify (requires --public-key) to LicenseMinterCli. After
writing the output file the CLI parses the freshly-minted token
through LicenseValidator against the supplied public key. On
verify failure the output file is deleted (so the bad token is
not accidentally shipped) and the CLI exits 3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 10:57:05 +02:00
hsiegeln
7300424a49 feat(license-minter): add LicenseMinterCli (without --verify)
Reads PEM or base64 PKCS#8 Ed25519 private key, maps --max-foo-bar
flags to max_foo_bar limit keys, parses --expires as a UTC date,
defaults --grace-days to 0. Unknown flags fail fast with exit 2.
--verify path is added in the next task.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 10:56:02 +02:00
hsiegeln
1ae5a1a27e feat(license-minter): implement LicenseMinter library
Pure signing primitive: serialises LicenseInfo to canonical JSON
(sorted top-level keys via ORDER_MAP_ENTRIES_BY_KEYS plus a TreeMap
for the limits sub-object) then signs with Ed25519. Round-trips
through LicenseValidator and is byte-stable across runs for
identical inputs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 10:55:11 +02:00
hsiegeln
896b7e6e91 feat(license-minter): add cameleer-license-minter Maven module
Top-level module sibling to cameleer-server-core/-app. Depends on
cameleer-server-core for the LicenseInfo schema. Spring Boot
repackage produces a runnable -cli classifier for the vendor.

Not added as a dependency from cameleer-server-app — runtime tree
must not carry signing primitives.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 10:54:19 +02:00
hsiegeln
0499a54ebc feat(license): rewrite LicenseGate around state + effective limits
LicenseGate now exposes getState() (delegates to LicenseStateMachine),
getEffectiveLimits() (merged over DefaultTierLimits in ACTIVE/GRACE,
defaults-only in ABSENT/EXPIRED/INVALID), markInvalid(reason), and
clear(). Internal snapshot is an immutable record-like class swapped
atomically so concurrent reads see a consistent license+reason pair.

Removes the transient openSentinel() and getTier() introduced by
earlier tasks (no production consumers).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 10:48:56 +02:00
hsiegeln
ddc0b686c3 feat(license): add LicenseLimits, DefaultTierLimits, LicenseStateMachine
Pure-domain FSM (ABSENT/ACTIVE/GRACE/EXPIRED/INVALID) and the
default-tier constants per spec §3. invalidReason wins over any
loaded license so signature failures surface as INVALID rather
than masking as ABSENT.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 10:47:10 +02:00
hsiegeln
cf84d80de7 feat(license): require licenseId + tenantId in validator
Spec §2.1 — both fields are required and the validator rejects a
token whose tenantId does not match the server's configured tenant
(CAMELEER_SERVER_TENANT_ID). Self-hosted customers cannot strip
tenantId because the field is in the signed payload.

LicenseBeanConfig and LicenseAdminController updated to pass the
expected tenant to the validator constructor. The transient
placeholder/TODO from Task 2 is removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 10:40:04 +02:00
hsiegeln
2ebe4989bb feat(license): expand LicenseInfo with licenseId, tenantId, grace period
Required fields per spec §2.1. tenantId is non-blank; gracePeriodDays
defines the post-exp window during which limits keep applying.
isExpired() now honours the grace; isAfterRawExpiry() distinguishes
ACTIVE from GRACE for the state machine in Task 4.

Validator and gate use placeholder values temporarily; Task 3 wires
the validator to read the new fields, Task 5 rewrites the gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 10:33:16 +02:00
hsiegeln
551a7f12b5 refactor(license): remove dead Feature enum and isEnabled scaffolding
Spec §9 — feature flags are out of scope for license enforcement.
Drops Feature.java, LicenseGate.isEnabled, LicenseInfo.hasFeature,
and the corresponding test cases. LicenseValidator now silently
ignores any features array on the wire (no error).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 10:21:51 +02:00
hsiegeln
ec51aef802 docs(license): implementation plan for license enforcement
36 tasks covering: dead-Feature removal; LicenseInfo/Limits/State
machine; standalone cameleer-license-minter Maven module + CLI with
--verify; Flyway V5 license table + environments retention columns;
LicenseRepository/Service/Enforcer/UsageReader; per-state cap-rejection
ControllerAdvice with rendered messages; wiring across Environment/
App/Agent/User/Outbound/AlertRule/Deployment compute caps; runtime
ClickHouse TTL applier on every LicenseChangedEvent; daily
revalidation job; usage endpoint; Prometheus gauges; ITs; OpenAPI
regen; .claude/rules updates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 10:09:28 +02:00
hsiegeln
e0be6a069f docs(license): apply review feedback to enforcement design
- Add INVALID state to FSM (signature/tenant/parse failure ≠ ABSENT)
  with loud UI/audit/metric severity; ABSENT stays a calm state.
- Make tenantId required in the license envelope (it's already inside
  the signed payload, so a self-hosted customer cannot strip it).
- Move ClickHouse TTL recompute from boot-only to a
  RetentionPolicyApplier @EventListener(LicenseChangedEvent), so a
  long-running server that lands in EXPIRED tightens TTL automatically.
- Add LicenseRevalidationJob (daily) that re-runs signature check
  against the DB row and updates last_validated_at; transitions to
  INVALID on failure (catches public-key rotation drift).
- Add last_validated_at column to the license table, surfaced on the
  /usage endpoint and as cameleer_license_last_validated_age_seconds.
- Enrich enforcement-failure responses and the /usage endpoint with a
  per-state human-readable message so 403s and the UI both explain
  WHY caps changed.
- Add --verify (with --public-key) to the minter CLI to round-trip a
  freshly-minted token through LicenseValidator before shipping it,
  deleting the output file on verify failure.
- Add corresponding tests, telemetry gauge, and a runtime-recompute IT.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 09:42:16 +02:00
hsiegeln
0e512a3c0c docs(license): brainstorm spec for license enforcement design
Captures the agreed design for enforcing licensing on cameleer-server:
- Default tier with hard caps when no license is configured
- Arbitrary per-customer limits in signed Ed25519 license tokens
- Standalone cameleer-license-minter module (vendor-only)
- DB-persisted license with env/file override paths
- ABSENT/ACTIVE/GRACE/EXPIRED state machine; offline expiry only
- Removes the dead Feature enum scaffolding

Pending writing-plans.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:55:18 +02:00
hsiegeln
f6b76b2d5e docs(runtime): document hardening contract and runtime override (#152)
Surfaces the multi-tenant container hardening contract introduced in the
prior commit so operators and integrators know what is enforced and why.

- application.yml: declare `cameleer.server.runtime.dockerruntime`
  alongside the other runtime properties (empty = auto-detect runsc).
- HOWTO.md: add the override row to the Runtime config table.
- SERVER-CAPABILITIES.md: new "Multi-Tenant Runtime Sandboxing" section
  describing the cap_drop, no-new-privileges, AppArmor, read-only rootfs,
  pids_limit, /tmp tmpfs, and runsc auto-detect contract — plus the
  on-disk state caveat that motivates issue #153.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:06:10 +02:00
hsiegeln
8e9ad47077 feat(runtime): harden tenant containers + auto-detect gVisor (#152)
Tenant JARs are arbitrary user code: Camel ships components (camel-exec,
camel-bean, MVEL/Groovy templating) that turn a header into shell, and
Java 17 has no SecurityManager — the JVM is not a security boundary.
This applies an unconditional hardening contract to every tenant
container so a single runc CVE no longer equals host takeover.

DockerRuntimeOrchestrator.startContainer now sets:
- cap_drop ALL (Capability.values() — docker-java has no ALL constant)
- security_opt: no-new-privileges, apparmor=docker-default
  (default seccomp profile applies implicitly)
- read_only rootfs, pids_limit=512
- /tmp tmpfs rw,nosuid,size=256m — no noexec, since Netty/Snappy/LZ4/Zstd
  dlopen native libs from /tmp via mmap(PROT_EXEC) which noexec blocks

The orchestrator also probes `docker info` at construction and uses runsc
(gVisor) automatically when the daemon has it registered. Override via
cameleer.server.runtime.dockerruntime (e.g. "kata"); empty = auto.

Outbound TCP, DNS, and TLS are unaffected — caps/seccomp don't gate
those — so vanilla Camel-Kafka producers/consumers and REST integrations
keep working unchanged. Stateful tenants (Kafka Streams with on-disk
state stores, apps writing to /var/log/...) need explicit writeable
volumes; that's tracked in #153 as the natural follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:58:26 +02:00
hsiegeln
c5b6f2bbad fix(dirty-state): exclude live-pushed fields from deploy diff
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m13s
CI / docker (push) Successful in 1m2s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 41s
SonarQube / sonarqube (push) Successful in 5m49s
Live-pushed config fields (taps, tapVersion, tracedProcessors,
routeRecording) apply via SSE CONFIG_UPDATE — they take effect on
running agents without a redeploy and are fetched on agent restart
from application_config. They must not contribute to the
"pending deploy" diff against the last-successful-deployment snapshot.

Before this fix, applying a tap from the process diagram correctly
rolled out in real time but then marked the app "Pending Deploy (1)"
because DirtyStateCalculator compared every agentConfig field. This
also contradicted the UI rule (ui.md) that the live tabs "never mark
dirty".

Adds taps, tapVersion, tracedProcessors, routeRecording to
AGENT_CONFIG_IGNORED_KEYS. Updates the nested-path test to use a
staged field (sensitiveKeys) and adds a new test asserting that
divergent live-push fields keep dirty=false.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:42:07 +02:00
83c3ac3ef3 Merge pull request 'feat(ui): show deployment status + rich pending-deploy tooltip on app header' (#151) from feature/deployment-status-badge into main
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 2m20s
CI / docker (push) Successful in 23s
CI / deploy (push) Successful in 43s
CI / deploy-feature (push) Has been skipped
Reviewed-on: #151
2026-04-24 13:50:00 +02:00
7dd7317cb8 Merge branch 'main' into feature/deployment-status-badge
Some checks failed
CI / cleanup-branch (pull_request) Has been skipped
CI / build (pull_request) Successful in 2m7s
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 2m6s
CI / docker (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / deploy-feature (pull_request) Has been skipped
CI / docker (push) Successful in 1m48s
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Failing after 2m19s
2026-04-24 13:49:51 +02:00
2654271494 Merge pull request 'feature/cmdk-attribute-filter' (#150) from feature/cmdk-attribute-filter into main
Some checks failed
CI / docker (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / deploy-feature (push) Has been cancelled
CI / cleanup-branch (push) Has been cancelled
CI / build (push) Has been cancelled
Reviewed-on: #150
2026-04-24 13:49:24 +02:00
hsiegeln
888f589934 feat(ui): show deployment status + rich pending-deploy tooltip on app header
Some checks failed
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m24s
CI / docker (push) Successful in 1m12s
CI / deploy (push) Has been cancelled
CI / deploy-feature (push) Has been cancelled
CI / cleanup-branch (pull_request) Has been skipped
CI / build (pull_request) Successful in 2m6s
CI / docker (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / deploy-feature (pull_request) Has been skipped
Add a StatusDot + colored Badge next to the app name in the deployment
page header, showing the latest deployment's status (RUNNING / STARTING
/ FAILED / STOPPED / DEGRADED / STOPPING). The existing "Pending
deploy" badge now carries a tooltip explaining *why*: either a list of
local unsaved edits, or a per-field diff against the last successful
deploy's snapshot (field, staged vs deployed values). When server-side
differences exist, the badge shows the count.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:47:04 +02:00
hsiegeln
9aad2f3871 docs(rules): document AttributeFilter + SearchController attr param
All checks were successful
CI / cleanup-branch (pull_request) Has been skipped
CI / build (pull_request) Successful in 1m50s
CI / docker (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / deploy-feature (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:22:27 +02:00
hsiegeln
cbaac2bfa5 feat(cmdk): Enter on 'key: value' query submits as attribute facet 2026-04-24 11:21:12 +02:00
hsiegeln
7529a9ce99 feat(cmdk): synthetic facet result when query matches key: value 2026-04-24 11:18:13 +02:00
hsiegeln
09309de982 fix(cmdk): attribute clicks filter the exchange list via ?attr= instead of opening one exchange 2026-04-24 11:13:28 +02:00
hsiegeln
56c41814fc fix(ui): gate AUTO badge on attributeFilters too 2026-04-24 11:11:26 +02:00
hsiegeln
68704e15b4 feat(ui): exchange list reads ?attr= URL params and renders filter chips
(carries forward pre-existing attribute-badge color-by-key tweak)
2026-04-24 11:05:50 +02:00
hsiegeln
510206c752 feat(ui): add attribute-filter URL and facet parsing helpers 2026-04-24 10:58:35 +02:00
hsiegeln
58e9695b4c chore(ui): regenerate openapi types with AttributeFilter 2026-04-24 10:39:45 +02:00
hsiegeln
f27a0044f1 refactor(search): align ResponseStatusException imports + add wildcard HTTP test 2026-04-24 10:30:42 +02:00
hsiegeln
5c9323cfed feat(search): accept attr= multi-value query param on /executions GET
Add a repeatable attr query parameter to the GET /executions endpoint that
parses key-only (exists check) and key:value (exact or wildcard-via-*)
filters. Invalid keys are mapped to HTTP 400 via ResponseStatusException.
The POST /executions/search path already honoured attributeFilters from
the request body via the Jackson canonical ctor; an IT now proves it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:23:52 +02:00
hsiegeln
2dcbd5a772 feat(search): push AttributeFilter list into ClickHouse WHERE clause
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:13:30 +02:00
hsiegeln
f9b5f235cc feat(search): extend SearchRequest with attributeFilters (legacy ctor preserved) 2026-04-24 09:59:05 +02:00
hsiegeln
0b419db9f1 feat(search): add AttributeFilter record with key regex + wildcard pattern translation 2026-04-24 09:51:28 +02:00
hsiegeln
5f6f9e523d chore(gitnexus): sync indexed symbol count
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:20:25 +02:00
hsiegeln
35319dc666 refactor(ui): server metrics page uses global time range
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m31s
CI / docker (push) Successful in 1m10s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 44s
Drop the page-local DS Select window picker. Drive from() / to() off
useGlobalFilters().timeRange so the dashboard tracks the same TopBar range
as Exchanges / Dashboard / Runtime. Bucket size auto-scales via
stepSecondsFor(windowSeconds) (10 s for ≤30 min → 1 h for >48 h). Query
hooks now take ServerMetricsRange = { from: Date; to: Date } instead of a
windowSeconds number, so they support arbitrary absolute or rolling ranges
the TopBar may supply (not just "now − N"). Toolbar collapses to just the
server-instance badges.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:19:20 +02:00
hsiegeln
3c2409ed6e docs(server-metrics): document the built-in admin dashboard
SERVER-CAPABILITIES.md now lists the two consumption paths (UI + REST API)
side-by-side with visibility rules; the dashboard-builder doc leads with a
"Built-in admin dashboard" section and a 2026-04-24 changelog entry so
first-time readers know they don't have to build anything before seeing
server health.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:05:22 +02:00
hsiegeln
ca401363ec chore(gitnexus): sync indexed symbol count
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m21s
CI / docker (push) Successful in 1m16s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 45s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:01:48 +02:00
hsiegeln
b5ee9e1d1f feat(ui): server metrics admin dashboard
Adds /admin/server-metrics page mirroring the Database/ClickHouse visibility
rules: sidebar entry gated on capabilities.infrastructureEndpoints, backend
controller now has @ConditionalOnProperty(infrastructureendpoints) and
class-level @PreAuthorize('hasRole(ADMIN)'). Dashboard panels are driven
from docs/server-self-metrics.md via the generic
/api/v1/admin/server-metrics/{catalog,instances,query} API — Server Health,
JVM, HTTP & DB pools, and conditionally Alerting + Deployments when their
metrics appear in the catalog. ThemedChart / Line / Area from the design
system; hooks in ui/src/api/queries/admin/serverMetrics.ts. Not yet
browser-verified against a running dev server — backend IT covers the API
end-to-end (8 tests), UI typecheck + production bundle both clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:00:14 +02:00
hsiegeln
75a41929c4 chore(gitnexus): sync indexed symbol count
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m34s
CI / docker (push) Successful in 1m4s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 41s
SonarQube / sonarqube (push) Successful in 4m54s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:42:26 +02:00
hsiegeln
d58c8cde2e feat(server): REST API over server_metrics for SaaS dashboards
Adds /api/v1/admin/server-metrics/{catalog,instances,query} so SaaS control
planes can build the server-health dashboard without direct ClickHouse
access. One generic /query endpoint covers every panel in the
server-self-metrics doc: aggregation (avg/sum/max/min/latest), group-by-tag,
filter-by-tag, counter-delta mode with per-server_instance_id rotation
handling, and a derived 'mean' statistic for timers. Regex-validated
identifiers, parameterised literals, 31-day range cap, 500-series response
cap. ADMIN-only via the existing /api/v1/admin/** RBAC gate. Docs updated:
all 17 suggested panels now expressed as single-endpoint queries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:41:02 +02:00
hsiegeln
64608a7677 chore(gitnexus): sync indexed symbol count
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m18s
CI / docker (push) Successful in 1m4s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 42s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:22:20 +02:00
hsiegeln
48ce75bf38 feat(server): persist server self-metrics into ClickHouse
Snapshot the full Micrometer registry (cameleer business metrics, alerting
metrics, and Spring Boot Actuator defaults) every 60s into a new
server_metrics table so server health survives restarts without an external
Prometheus. Includes a dashboard-builder reference for the SaaS team.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:20:45 +02:00
hsiegeln
0bbe5d6623 chore(gitnexus): sync indexed symbol count
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 19:18:49 +02:00
hsiegeln
e1ac896a6e chore(gitnexus): refresh indexed symbol count
Second analyze pass after pushing showed a slightly different symbol
count. Counts-only bump.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 19:17:45 +02:00
hsiegeln
58009d7c23 chore(gitnexus): refresh indexed symbol/relationship counts
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m14s
CI / docker (push) Successful in 1m4s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 42s
Auto-bumped by `npx gitnexus analyze --embeddings` after the diagram
refactor landed. No content changes — counts only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 19:15:08 +02:00
hsiegeln
b799d55835 fix(ui): sidebar catalog counts follow global time range
useCatalog now accepts optional from/to query params and LayoutShell
threads the TopBar time range through, so the per-app exchange counts
shown in the sidebar align with the Exchanges tab window. Previously
the sidebar relied on the backend's 24h default — 73.5k in the sidebar
coexisted with 0 hits in a 1h Exchanges search, confusing users.

Other useCatalog callers stay on the default (no time range), matching
their existing behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 19:15:01 +02:00
hsiegeln
166568edea fix(ui): preserve environment selection across logout
handleLogout explicitly cleared the env from localStorage, forcing the
env switcher modal to re-open on every login. Drop that clear so the
last selected env is restored from localStorage on the next session —
the expected behavior for a personal-preference store.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 19:14:30 +02:00
hsiegeln
f049a0a6a0 docs(rules): capture new DiagramStore method and registry-free lookup
- app-classes: DiagramRenderController by-route endpoint no longer
  depends on the agent registry; points at findLatestContentHashForAppRoute
  and cross-refs the exchange viewer's content-hash path.
- core-classes: document the new DiagramStore method and note why the
  agent-scoped findContentHashForRoute stays for the ingest path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 19:11:45 +02:00
hsiegeln
f8e382c217 test(diagrams): add removed-route + point-in-time coverage
Store-level: assert findLatestContentHashForAppRoute picks the newest
hash across publishing instances (proves the lookup survives agent
removal), isolates by (app, env), and returns empty for blank inputs.

Controller-level: assert the env-scoped /routes/{routeId}/diagram
endpoint resolves without a registry prerequisite, 404s for unknown
routes, and that an execution's stored diagramContentHash stays pinned
to the point-in-time version after a newer diagram is stored — the
"latest" endpoint flips to v2, the by-hash render remains byte-stable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 19:11:06 +02:00
hsiegeln
c7e5c7fa2d refactor(diagrams): retire findContentHashForRouteByAgents
All production callers migrated to findLatestContentHashForAppRoute in
the preceding commits. The agent-scoped lookup adds no coverage beyond
the latest-per-(app,env,route) resolver, so the dead API is removed
along with its test coverage and unused imports.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 19:02:47 +02:00
hsiegeln
0995ab35c4 fix(catalog): preserve fromEndpointUri for removed routes
Both catalog controllers resolved the from-endpoint URI via
findContentHashForRouteByAgents, which filtered by the currently-live
agent instance_ids. Routes removed between app versions therefore lost
their fromUri even though the diagram row still exists.

Route through findLatestContentHashForAppRoute so resolution depends
only on (app, env, route) — stays populated for historical routes.
CatalogController now resolves the per-row env slug up-front so the
fromUri lookup works even for cross-env queries against managed apps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 19:01:19 +02:00
hsiegeln
480a53c80c fix(diagrams): by-route lookup no longer requires live agents
The env-scoped /routes/{routeId}/diagram endpoint filtered diagrams by
the currently-live agent instance_ids. Routes removed between app
versions have no live publisher, so the lookup returned 404 even though
the historical diagram row still exists in route_diagrams. Sidebar
entries for removed routes showed "no diagram" as a result.

Switch to findLatestContentHashForAppRoute which resolves directly off
(applicationId, environment, routeId) + created_at DESC, independent of
the agent registry. The controller no longer depends on
AgentRegistryService.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:59:43 +02:00
hsiegeln
d3ce5e861b feat(diagrams): add findLatestContentHashForAppRoute with app-route cache
Agent-scoped lookups miss diagrams from routes whose publishing agents
have been redeployed or removed. The new method resolves by
(applicationId, environment, routeId) + created_at DESC, independent of
the agent registry. An in-memory cache mirrors the existing hashCache
pattern, warm-loaded at startup via argMax.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:58:49 +02:00
hsiegeln
e5c8fff0f9 docs(HOWTO): document CAMELEER_SERVER_RUNTIME_CERTRESOLVER env var
Added the new Traefik TLS cert resolver setting to the runtime env var
table. Blank default matches how ACME-less dev/local installs want the
`tls.certresolver` label omitted entirely.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:22:27 +02:00
hsiegeln
21db92ff00 fix(traefik): make TLS cert resolver configurable, omit when unset
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m15s
CI / docker (push) Successful in 1m3s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 42s
Previously `TraefikLabelBuilder` hardcoded `tls.certresolver=default` on
every router. That assumes a resolver literally named `default` exists
in the Traefik static config — true for ACME-backed installs, false for
dev/local installs that use a file-based TLS store. Traefik logs
"Router uses a nonexistent certificate resolver" for the bogus resolver
on every managed app, and any future attempt to define a differently-
named real resolver would silently skip these routers.

Server-wide setting via `CAMELEER_SERVER_RUNTIME_CERTRESOLVER` (empty by
default) flows through `ConfigMerger.GlobalRuntimeDefaults.certResolver`
into `ResolvedContainerConfig.certResolver`. When blank the
`tls.certresolver` label is omitted entirely; `tls=true` is still
emitted so Traefik serves the default TLS-store cert. When set, the
label is emitted with the configured resolver name.

Not per-app/per-env configurable: there is one Traefik per server
instance and one resolver config; app-level override would only let
users break their own routers.

TDD: TraefikLabelBuilderTest gains 3 cases (resolver set, null, blank).
Full unit suite 211/0/0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:18:47 +02:00
hsiegeln
165c9f10e3 feat(deploy): externalRouting toggle to keep apps off Traefik
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m26s
CI / docker (push) Successful in 1m5s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 41s
Adds a boolean `externalRouting` flag (default `true`) on
ResolvedContainerConfig. When `false`, TraefikLabelBuilder emits only
the identity labels (`managed-by`, `cameleer.*`) and skips every
`traefik.*` label, so the container is not published by Traefik.
Sibling containers on `cameleer-traefik` / `cameleer-env-{tenant}-{env}`
can still reach it via Docker DNS on whatever port the app listens on.

TDD: new TraefikLabelBuilderTest covers enabled (default labels present),
disabled (zero traefik.* labels), and disabled (identity labels retained)
cases. Full module unit suite: 208/0/0.

Plumbed through ConfigMerger read, DeploymentExecutor snapshot, UI form
state, Resources tab toggle, POST payload, and snapshot-to-form mapping.
Rule files updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:03:48 +02:00
hsiegeln
ade1733418 ui(deploy): remove Exposed Ports field from Resources tab
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m25s
CI / docker (push) Successful in 1m4s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 41s
The field was cosmetic — `containerConfig.exposedPorts` only fed Docker's
`Config.ExposedPorts` metadata via `withExposedPorts(...)`. It never
published a host port and Traefik routing uses `appPort` from the label
builder, not this list. Users reading the label "Exposed Ports" reasonably
expected it to expose their port externally; removing it until real
multi-port Traefik routing lands (tracked in #149).

Backend DTOs (`ContainerRequest.exposedPorts`, `ConfigMerger.intList
("exposedPorts")`) are left in place so existing containerConfig JSONB
rows continue to deserialize. New writes from the UI will no longer
include the field.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:51:46 +02:00
hsiegeln
0cf64b2928 fix(audit): exclude env-scoped executions/search from safety-net log
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m24s
CI / docker (push) Successful in 1m1s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 37s
The exclusion list still named the legacy flat `/api/v1/search/executions`
URL, which no longer exists — the endpoint moved to env-scoped
`/api/v1/environments/{envSlug}/executions/search`. Exact-match Set
lookup never matched, so every UI search POST produced an audit row.

Switch to AntPathMatcher over a pattern list so the dynamic envSlug is
handled correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:35:44 +02:00
hsiegeln
0fc9c8cb4c docs(rules): checkpoints live inside Identity grid; HistoryDisclosure retired
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m21s
CI / docker (push) Successful in 1m6s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 38s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:15:05 +02:00
hsiegeln
fe4a6dbf24 ui(deploy): remove redundant HistoryDisclosure from Deployment tab 2026-04-23 17:13:45 +02:00
hsiegeln
9cfe3985d0 refactor(ui): route CheckpointsTable via IdentitySection.checkpointsSlot 2026-04-23 17:12:12 +02:00
hsiegeln
18da187960 refactor(ui): checkpoints in-grid styles + drop retired row-list/history CSS 2026-04-23 17:10:42 +02:00
hsiegeln
9c1bd24f16 test(ui): CheckpointsTable covers fragment layout + locale sub-line 2026-04-23 17:08:57 +02:00
hsiegeln
177673ba62 feat(ui): CheckpointsTable emits grid fragment + locale sub-line 2026-04-23 17:03:31 +02:00
hsiegeln
77f5c82dfe feat(ui): IdentitySection accepts checkpointsSlot rendered inside configGrid 2026-04-23 17:01:52 +02:00
hsiegeln
663a6624a7 docs(plan): checkpoints grid row + locale time + remove History (7 TDD tasks)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 16:54:42 +02:00
hsiegeln
cc3cd610b2 docs(spec): checkpoints into identity grid + locale time + remove History
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 16:51:08 +02:00
hsiegeln
b6239bdb6b docs(rules): reflect deployment page polish (upload-in-button, sort/refresh, collapsible checkpoints, DS Select, tab reorder)
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m21s
CI / docker (push) Successful in 1m8s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 38s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 16:16:52 +02:00
hsiegeln
0ae27ad9ed ui(drawer): reorder tabs Config first, default to Config 2026-04-23 16:15:29 +02:00
hsiegeln
e00848dc65 refactor(ui): drawer replica filter uses DS Select 2026-04-23 16:13:54 +02:00
hsiegeln
f31975e0ef feat(ui): checkpoints table collapsible, default collapsed 2026-04-23 16:09:28 +02:00
hsiegeln
2c0cf7dc9c fix(ui): StartupLogPanel — defensive scrollTo + disable buttons while fetching 2026-04-23 16:05:35 +02:00
hsiegeln
fb7b15f539 feat(ui): startup logs — sort toggle + refresh button + desc default 2026-04-23 16:00:44 +02:00
hsiegeln
1d7009d69c feat(ui): useStartupLogs accepts sort parameter (default desc) 2026-04-23 15:58:02 +02:00
hsiegeln
99a91a57be feat(ui): wire JAR upload progress into the primary action button 2026-04-23 15:54:23 +02:00
hsiegeln
427988bcc8 feat(ui): PrimaryActionButton gains uploading mode + progress overlay 2026-04-23 15:49:27 +02:00
hsiegeln
a208f2eec7 feat(ui): useUploadJar uses XHR and exposes onProgress 2026-04-23 15:44:50 +02:00
hsiegeln
13f218d522 docs(plan): deployment page polish (9 TDD tasks)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 15:42:06 +02:00
hsiegeln
900fba5af6 docs(spec): deployment page polish (upload-in-button, sort/refresh, collapsible checkpoints, DS Select, tab reorder)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 15:36:57 +02:00
hsiegeln
b3d1dd377d ui(deploy): hide CheckpointsTable when no past deployments exist
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 14:34:09 +02:00
hsiegeln
e36c82c4db test(deploy): scope schema ITs to current_schema + clear deployments FK in teardown
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m59s
CI / docker (push) Successful in 1m5s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 38s
Surface from the Task 0 testcontainers.reuse enable: when the same Postgres
container is reused across `mvn verify` runs, Flyway migrates both `public`
and `tenant_default` schemas (the app.yml default URL uses
?currentSchema=tenant_default; AbstractPostgresIT overrides to public).
Schema-introspection assertions saw duplicate rows/indexes/enums.

Plus: OutboundConnectionAdminControllerIT's AfterEach couldn't delete its
test users because sibling deployment ITs (Task 4) left deployments.created_by
references — FK blocks the DELETE. Clear referencing deployments first.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 14:06:56 +02:00
hsiegeln
d192f6b57c docs(rules): deployment audit + checkpoints table + SideDrawer + log instanceIds
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 13:51:22 +02:00
hsiegeln
fe1681e6e8 ui(audit): surface DEPLOYMENT category in admin filter dropdown 2026-04-23 13:49:31 +02:00
hsiegeln
571f85cd0f feat(ui): wire CheckpointsTable + Drawer into IdentitySection (delete old Checkpoints)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 13:46:31 +02:00
hsiegeln
25d2a3014a refactor(ui): DiffView CSS module + drop duplicate snapshot type 2026-04-23 13:43:15 +02:00
hsiegeln
1a97e2146e feat(ui): ConfigPanel snapshot+diff modes; extract snapshotToForm helper
- Extract inline handleRestore mapping into snapshotToForm(snapshot, defaults) helper
- Export defaultForm from useDeploymentPageState for use in ConfigPanel
- Replace ConfigPanel stub with real read-only snapshot renderer + Snapshot/Diff toggle
- Add fieldDiff deep-equal field-walk helper with nested object + array support
- Forward optional currentForm prop through CheckpointDetailDrawer to ConfigPanel
- 13 new tests across diff.test.ts, snapshotToForm.test.ts, ConfigPanel.test.tsx (all pass)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 13:38:22 +02:00
hsiegeln
d1150e5dd8 refactor(ui): drawer CSS module + narrow LogsPanel memo deps
Extract 14 inline style blocks from CheckpointDetailDrawer index.tsx and
LogsPanel.tsx into a shared CSS module using DS CSS variables throughout.
Narrow the LogsPanel useMemo dep array from the full deployment object to
deployment.id + deployment.replicaStates to prevent spurious query
invalidation on every TanStack Query poll.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 13:30:48 +02:00
hsiegeln
b0995d84bc feat(ui): CheckpointDetailDrawer container + LogsPanel
Adds the CheckpointDetailDrawer with Logs/Config tabs. LogsPanel scopes
logs to a deployment's replicas via instanceIds derived from replicaStates
+ generation suffix. Stub ConfigPanel placeholder for Task 11.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 13:25:55 +02:00
hsiegeln
9756a20223 fix(ui): dim archived checkpoint rows + safer outcome class lookup + cleaner cap 2026-04-23 13:19:06 +02:00
hsiegeln
1b4b522233 feat(ui): CheckpointsTable component (replaces row list)
Full-width table with Version / JAR / Deployed-by / Deployed / Strategy /
Outcome columns, pagination cap (jarRetentionCount, default 10), pruned-JAR
archived state, empty state, and row-click onSelect handler. 8/8 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 13:15:30 +02:00
hsiegeln
48217e0034 test(deploy): contract test — ConfigTabs disabled gates all inputs 2026-04-23 13:10:17 +02:00
hsiegeln
c3ecff9d45 feat(ui): add SideDrawer component (project-local)
Right-sliding panel with portal, ESC + backdrop close, sticky header/footer,
three width sizes (md/lg/xl), transparent click-blocking backdrop, and DS token colors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 13:05:36 +02:00
hsiegeln
07099357af chore(api): regenerate UI types — Deployment.createdBy + logs instanceIds
- Fetched fresh openapi.json from local backend (Tasks 3-5 changes)
- Regenerated schema.d.ts via openapi-typescript
- Added createdBy: string | null to Deployment interface in apps.ts
- Added instanceIds?: string[] to UseInfiniteApplicationLogsArgs with sort/serialize/queryKey/URLSearchParams wiring

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 13:00:16 +02:00
hsiegeln
ed0e616109 refactor(logs): drop dead null guards on instanceIds filter (record normalizes) 2026-04-23 12:52:18 +02:00
hsiegeln
382e1801a7 feat(logs): add instanceIds multi-value filter to /logs endpoint
Adds List<String> instanceIds to LogSearchRequest (null-normalized to
List.of() in compact ctor) and generates an IN clause in both
ClickHouseLogStore.search() and countLogs(), mirroring the existing
sources pattern. LogQueryController parses ?instanceIds= as a
comma-split list. All existing LogSearchRequest call sites updated.
New ClickHouseLogStoreInstanceIdsIT covers: multi-value filter, empty
filter (all rows), null filter (all rows), single-value filter, and
coexistence with the singular instanceId field.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 12:41:09 +02:00
hsiegeln
2312a7304d fix(deploy): widen promote FAILURE audit detail + clean up test envs 2026-04-23 12:29:46 +02:00
hsiegeln
47d5611462 feat(audit): audit deploy/stop/promote with DEPLOYMENT category
Wires AuditService and AppVersionRepository into DeploymentController.
Replaces null createdBy placeholder with currentUserId() on createDeployment/promote.
Adds audit log entries (SUCCESS + FAILURE) for deploy_app, stop_deployment,
and promote_deployment actions. Fixes FK violations in affected ITs by
seeding the test-operator and alice users into the users table before deploy calls.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 12:24:27 +02:00
hsiegeln
9043dc00b0 test(deploy): clean up seeded users + document null createdBy placeholder
Fix Issue 1: Add @AfterEach cleanup for alice/bob users in PostgresDeploymentRepositoryCreatedByIT to prevent test leakage (FK order: deployments -> app_versions -> apps, then users).

Fix Issue 2: Add comment at first create(..., null) call site in PostgresDeploymentRepositoryIT documenting the null placeholder for pre-V4 rows where createdBy is nullable.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-23 12:10:21 +02:00
hsiegeln
a141e99a07 feat(deploy): cascade createdBy through Deployment record + service + repo
Appends String createdBy to the Deployment record (after createdAt), updates
both with-er methods to pass it through, threads the parameter through
DeploymentRepository.create, DeploymentService.createDeployment/promote, and
PostgresDeploymentRepository (INSERT + SELECT_COLS + mapRow). DeploymentController
passes null as placeholder (Task 4 will resolve from SecurityContextHolder).
Covers with PostgresDeploymentRepositoryCreatedByIT verifying round-trip via
both createDeployment and promote.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 12:04:15 +02:00
hsiegeln
15d00f039c feat(audit): add DEPLOYMENT audit category 2026-04-23 11:51:28 +02:00
hsiegeln
064c302073 docs(plan): V2 → V4 migration filename (V2/V3 already taken) 2026-04-23 11:49:12 +02:00
hsiegeln
35748ea7a1 feat(deploy): V4 migration — add created_by to deployments 2026-04-23 11:44:05 +02:00
hsiegeln
e558494f8d plan(deploy): checkpoints table redesign + audit gap
15 tasks across 5 phases (backend foundation → SideDrawer →
ConfigTabs readOnly → CheckpointsTable + DetailDrawer → polish).
TDD throughout with per-task commits. Backend phase ships
independently to close the audit gap as quickly as possible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:39:11 +02:00
hsiegeln
1f0ab002d6 spec(deploy): checkpoints table redesign + deployment audit gap
Replaces the cramped Checkpoints disclosure with a real DataTable + a
side drawer (Logs / Config with snapshot/diff modes) and closes the
audit-log gap discovered in DeploymentController (deploy/stop/promote
currently make zero auditService.log calls).

Cap visible checkpoints at Environment.jarRetentionCount — beyond that,
JARs are pruned and rows aren't restorable. Logs scoped per-deployment
via instance_id IN (...) computed from replicaStates (no time window
needed). Compare folded into Config as a view-mode toggle. Two-phase
rollout (backend ships first to close the audit gap immediately).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:31:50 +02:00
hsiegeln
242ef1f0af perf(build): faster Maven + UI + CI pipelines
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m43s
CI / docker (push) Successful in 4m13s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 41s
- Maven: enable useIncrementalCompilation; Surefire forkCount=1C +
  reuseForks=true so unit-test JVMs are reused per CPU core instead of
  spawning per class (205 tests pass under the new strategy).
- Testcontainers: opt-in reuse via .withReuse(true) on Postgres +
  ClickHouse base; per-developer enable via ~/.testcontainers.properties.
- UI: drop redundant `tsc --noEmit` from `npm run build` (Vite already
  type-checks); split into a dedicated `npm run typecheck` script.
- CI: cache ~/.npm and ui/node_modules/.vite alongside Maven; npm ci with
  --prefer-offline --no-audit --fund=false; paths-ignore for docs-only,
  .planning/ and .claude/ changes so doc-only pushes skip the pipeline.
- Docs: CLAUDE.md + .claude/rules/cicd.md updated with the new build
  knobs and the Testcontainers reuse opt-in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:48:34 +02:00
hsiegeln
c6aef5ab35 fix(deploy): Checkpoints — preserve STOPPED history, fix filter + placement
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 2m4s
CI / docker (push) Successful in 1m15s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 41s
- Backend: rename deleteTerminalByAppAndEnvironment → deleteFailedByAppAndEnvironment.
  STOPPED rows were being wiped on every redeploy, so Checkpoints was always empty.
  Now only FAILED rows are pruned; STOPPED deployments are retained as restorable
  checkpoints (they still carry deployed_config_snapshot from their RUNNING window).
- UI filter: any deployment with a snapshot is a checkpoint (was RUNNING|DEGRADED only,
  which excluded the main case — the previous blue/green deployment now in STOPPED).
- UI placement: Checkpoints disclosure now renders inside IdentitySection, matching
  the design spec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:26:46 +02:00
hsiegeln
007597715a docs(rules): deployment strategies + generation suffix
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 2m8s
CI / docker (push) Successful in 1m30s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 46s
Refresh the three rules files to match the new executor behavior:

- docker-orchestration.md: rewrite DeploymentExecutor Details with
  container naming scheme ({...}-{replica}-{generation}), strategy
  dispatch (blue-green vs rolling), and the new DEGRADED semantics
  (post-deploy only). Update TraefikLabelBuilder + ContainerLogForwarder
  bullets for the generation suffix + new cameleer.generation label.
- app-classes.md: DeploymentExecutor + TraefikLabelBuilder bullets
  mirror the same.
- core-classes.md: add DeploymentStrategy enum; note DEGRADED is now
  post-deploy-only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:02:51 +02:00
hsiegeln
b6e54db6ec ui(deploy): strategy hint on Resources tab + indicator on StatusCard
Resources tab: add a hint under the Deploy Strategy dropdown that
explains the blue-green vs rolling trade-off (resource peak, failure
semantics), switching text based on the current selection.

StatusCard: show the active deployment's strategy inline in the info
grid so users can tell at a glance which path was taken for a given
deployment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:00:44 +02:00
hsiegeln
e9f523f2b8 test(deploy): blue-green + rolling strategy ITs
Four ITs covering strategy behavior:
- BlueGreenStrategyIT#blueGreen_allHealthy_stopsOldAfterNew:
  old is stopped only after all new replicas are healthy.
- BlueGreenStrategyIT#blueGreen_partialHealthy_preservesOldAndMarksFailed:
  strict all-healthy — one starting replica aborts the deploy and
  leaves the previous deployment RUNNING untouched.
- RollingStrategyIT#rolling_allHealthy_replacesOneByOne:
  InOrder on stopContainer confirms old-0 stops before old-1 (the
  interleaving that distinguishes rolling from blue-green).
- RollingStrategyIT#rolling_failsMidRollout_preservesRemainingOld:
  mid-rollout health failure stops only the in-flight new containers
  and the already-replaced old-0; old-1 stays untouched.

Shortens healthchecktimeout to 2s via @TestPropertySource so failure
paths complete in ~25s instead of ~60s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:00:00 +02:00
hsiegeln
653f983a08 deploy: rolling strategy (per-replica replacement)
Replace the Phase 3 stub with a working rolling implementation.

Flow:
- Capture previous deployment's per-index container ids up front.
- For i = 0..replicas-1:
  - Start new[i] (gen-suffixed name, coexists with old[i]).
  - Wait for new[i] healthy (new waitForOneHealthy helper).
  - On success: stop old[i] if present, continue.
  - On failure: stop in-flight new[0..i], leave un-replaced old[i+1..N]
    running, mark FAILED. Already-replaced old replicas are not
    restored — rolling is not reversible; user redeploys to recover.
- After the loop: sweep any leftover old replicas (when replica count
  shrank) and mark the old deployment STOPPED.

Resource peak: replicas + 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 09:53:52 +02:00
hsiegeln
459cdfe427 deploy: blue-green strategy (start → health-all → stop old)
Phase 3 of deployment-strategies plan. Refactor executeAsync to
dispatch on DeploymentStrategy.fromWire(config.deploymentStrategy()).

Blue-green (default):
- Start all N new replicas (gen-suffixed names coexist with old).
- Wait for ALL healthy (strict — partial-healthy = FAILED, preserves
  previous deployment untouched).
- Only then find + stop the previous deployment.
- Final status is always RUNNING; DEGRADED is now reserved for
  post-deploy replica crashes (set by DockerEventMonitor).

Rolling: stub — throws UnsupportedOperationException for now, gets
its real implementation in Phase 4.

Refactor details:
- Extract DeployCtx record to carry 13 per-deploy values around.
- Extract startReplica(ctx, i, stateOut) — shared by both strategy paths.
- Extract persistSnapshotAndMarkRunning(ctx, primaryCid) — shared finalizer.
- Rename waitForAnyHealthy → waitForAllHealthy (the name was misleading;
  the method already waited for all, just returned partial on timeout).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 09:51:24 +02:00
hsiegeln
652346dcd4 deploy: gen-suffixed container names + cameleer.generation label
Append an 8-char generation id (first 8 chars of deployment UUID) to:
- container name: {tenant}-{env}-{app}-{replica}-{gen}
- CAMELEER_AGENT_INSTANCEID (so old+new agents are distinct in the registry)
- Traefik cameleer.instance-id label

And emit a new standalone cameleer.generation label so dashboards
(Prometheus/Grafana) can pin deploy boundaries without regex on
instance-id.

Strategy branching comes next — this commit is foundation only; the
interim destroy-then-start flow still runs regardless of strategy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 09:45:44 +02:00
hsiegeln
5304c8ee01 core(deploy): DeploymentStrategy enum with safe wire conversion
Typed enum (BLUE_GREEN, ROLLING) with fromWire/toWire kebab-case
translation. fromWire falls back to BLUE_GREEN for unknown or null
input so the executor dispatch site never null-checks and no
misconfigured container-config can throw at runtime.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 09:42:35 +02:00
hsiegeln
2c82f29aef docs(plans): deployment strategies (blue-green + rolling) plan
7-phase plan to replace the interim destroy-then-start flow (f8dccaae)
with a strategy-aware executor. Adds gen-suffixed container names so
old + new replicas can coexist, plus a cameleer.generation label for
Prometheus/Grafana deploy-boundary annotations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 09:41:43 +02:00
hsiegeln
4371372a26 ui(admin): solid env-colored circle in place of name-hash Avatar
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 2m7s
CI / docker (push) Successful in 1m21s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 41s
SonarQube / sonarqube (push) Successful in 6m8s
Previous ring approach was too subtle against most env colors. Replace
the DS Avatar with a purpose-built circle rendered in the environment's
chosen color, showing 1–2 letter initials in white. Fills the full
circle so the color reads at a glance from across the list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 01:02:10 +02:00
hsiegeln
f8dccaae2b fix(deploy): stop previous active deployment before START_REPLICAS (fixes 409)
Container names are deterministic: {tenant}-{envSlug}-{appSlug}-{replica}.
The prior code did the stop-existing step at SWAP_TRAFFIC, *after*
START_REPLICAS had already tried to create containers with the same
names — so a redeploy against a RUNNING app consistently failed with
Docker 409 "container name already in use".

Move the stop-existing block to run right after CREATE_NETWORK and
before START_REPLICAS. SWAP_TRAFFIC becomes a label-only marker (traffic
is swapped implicitly by Traefik labels once new replicas are healthy).

Also: add `findActiveByAppIdAndEnvironmentIdExcluding` so the SQL
excludes the current deployment by id — previously the Java-side
`!id.equals(me)` guard failed because the newly-inserted row has
status=STARTING (DB default) and ORDER BY created_at DESC LIMIT 1
picked the new row, hiding the actual previous deployment.

Trade-off: this is destroy-then-start rather than true blue/green —
brief downtime during the swap. Matches the pre-unified-page behavior
and is what users reasonably expect. True blue/green would require
per-deployment container names.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 01:01:00 +02:00
hsiegeln
9ecc9ee72a ui(deploy): pending-deploy badge + Start/Stop in page header
1. Add a 'Pending deploy' Badge next to the app name when there are
   local edits or the saved state differs from the last deploy. Makes
   the undeployed-changes state visible even when the user isn't looking
   at the tab asterisks.

2. Move Start/Stop buttons from StatusCard into the page header, next
   to Delete. Runs off the latest deployment's status — Stop when
   RUNNING/STARTING/DEGRADED, Start (triggers a redeploy of the last
   version) when STOPPED. DeploymentTab and StatusCard shed their
   onStop/onStart props.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:51:11 +02:00
hsiegeln
9c54313ff1 ui(deploy): surface deployment failure reason in StatusCard
DeploymentExecutor already persists errorMessage on FAILED transitions
but the UI never rendered it — users saw "FAILED" with no explanation.
Add a bordered error block above the action row when a deployment is
FAILED, preserving whitespace and wrapping long Docker error bodies
(e.g. 409 conflict JSON).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:49:29 +02:00
hsiegeln
e5eb48b0fa ui(admin): env-colored ring on environment avatars
Wrap Avatar in a span with box-shadow outline in the environment's
chosen color (slate/red/amber/green/teal/blue/purple/pink). Applied to
both the list row and the detail header. Keeps the Avatar's name-hash
interior so initials remain distinguishable; the ring just signals
which env you're looking at at a glance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:48:51 +02:00
hsiegeln
b655de3975 fix(config): structured 400 body on unknown apply value
Replace empty-body ResponseEntity.status(BAD_REQUEST).build() with
ResponseStatusException so Spring returns the usual error body shape
with a descriptive reason string, matching the idiom used by
UserAdminController, AppSettingsController, ThresholdAdminController.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:45:31 +02:00
hsiegeln
4e19f925c6 ui(deploy): loading-aware default for dirty-state baseline
Previously `dirtyState?.dirty ?? true` caused a stale `Redeploy` label
to flash briefly while the first fetch was in flight. Gate the default
on isLoading so the button starts as `Save (disabled)` until the
endpoint resolves — spurious Redeploy clicks were harmless but the
loading-state UX was wrong.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:42:48 +02:00
hsiegeln
8a7f9cb370 fix(deploy): compare samplingRate as number in dirty detection
Drop the Number.isInteger normalization hack in useDeploymentPageState
that mapped 1.0 → "1.0" but broke for values like 1.10 (which round-trip
to 1.1). Instead, useFormDirty now parseFloats samplingRate on both sides
before comparing, so "1", "1.0", and "1.00" all compare equal regardless
of how the backend serializes the number.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:41:33 +02:00
hsiegeln
b5ecd39100 docs(api): document ?apply query param on updateConfig (Swagger)
Adds @Parameter description so the generated OpenAPI spec / Swagger UI
explains what 'staged' vs 'live' means instead of just surfacing the
bare param name. Follow-up: run `cd ui && npm run generate-api:live`
against a live backend to refresh openapi.json + schema.d.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:39:10 +02:00
hsiegeln
629a009b36 ui(deploy): scrollIntoView when expanding a history row
On long deployment histories the StartupLogPanel would render off-screen
when the user clicked a row. Ref + useEffect scrolls the panel into view
with block:'nearest' so expanding a row that's already in view doesn't
cause a disorienting jump.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:38:23 +02:00
hsiegeln
ffdaeabc9f test(deploy): lock in FAILED→null snapshot for health-check-fail path
Existing IT only exercises the startContainer-throws path, where the
exception bypasses the entire try block. Add a test where startContainer
succeeds but getContainerStatus never returns healthy — this covers the
early-exit at the HEALTH_CHECK stage, which is the common real-world
failure shape and closest to the snapshot-write point.

Shortens healthchecktimeout to 2s via @TestPropertySource so the test
completes in a few seconds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:37:37 +02:00
hsiegeln
703bd412ed fix(deploy): toast when restoring checkpoint with no snapshot
handleRestore previously returned silently when deployedConfigSnapshot
was null, leaving the user wondering why their click did nothing. Show
a warning toast explaining that the checkpoint predates snapshotting.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:34:45 +02:00
hsiegeln
4d4c59efe3 fix(deploy): include DEGRADED deploys as restorable checkpoints
Snapshot is written by DeploymentExecutor before the RUNNING/DEGRADED
split, so DEGRADED rows already carry a deployed_config_snapshot. Treat
them as checkpoints — partial-healthy deploys still produced a working
config worth restoring. Aligns repo query with UI filter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:34:25 +02:00
hsiegeln
837e5d46f5 docs(deploy): session handoff + refresh GitNexus index stats
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 2m9s
CI / docker (push) Successful in 1m17s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 38s
Handoff summarises the unified deployment page implementation (spec,
plan, 43 commits, opened Gitea issues #147 and #148), open gaps, and
recommended kickoff for the next session.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:17:26 +02:00
hsiegeln
0a71bca7b8 fix(deploy): redeploy button after save, disable save when clean, success toast
- Bug 1: default serverDirtyAgainstDeploy to true (not false) while
  dirtyState query is loading — prevents the button showing 'Save'
  instead of 'Redeploy' on apps with no successful deployment yet.
- Bug 2: normalize samplingRate from server as '<n>.0' when the value
  is a whole-number float so serverState matches form after save,
  eliminating spurious dirty detection that kept Save enabled.
- Bug 3: add success toast after handleSave completes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 00:06:00 +02:00
hsiegeln
b7b6bd2a96 ui(deploy): port missing agent-config fields, var-view switcher, env pill, tab seam
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 23:45:19 +02:00
hsiegeln
d33c039a17 fix(deploy): address final review — sensitiveKeys snapshot, dirty scrubbing, transition race, refetch invalidations
- Issue 1: add List<String> sensitiveKeys as 4th field to DeploymentConfigSnapshot; populate
  from agentConfig.getSensitiveKeys() in DeploymentExecutor; handleRestore hydrates from
  snap.sensitiveKeys directly; Deployment type in apps.ts gains sensitiveKeys field
- Issue 2: after createApp succeeds, refetchQueries(['apps', envSlug]) before navigate so the
  new app is in cache before the router renders the deployed view (eliminates transient Save-
  disabled flash)
- Issue 3: useDeploymentPageState useEffect now uses prevServerStateRef to detect local edits;
  background refetches only overwrite form when no local changes are present
- Issue 5: handleRedeploy invalidates dirty-state + versions queries after createDeployment
  resolves; handleSave invalidates dirty-state after staged save
- Issue 10: DirtyStateCalculator strips volatile agentConfig keys (version, updatedAt, updatedBy,
  environment, application) before JSON comparison via scrubAgentConfig(); adds
  versionBumpDoesNotMarkDirty test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 23:29:01 +02:00
hsiegeln
6d5ce60608 docs(rules): document ?apply flag + snapshot column in app-classes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 23:17:25 +02:00
hsiegeln
d595746830 docs(rules): update ui.md Deployments bullet for unified deployment page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 23:16:59 +02:00
hsiegeln
5a7c0ce4bc ui(deploy): delete CreateAppView + AppDetailView + ConfigSubTab
AppsTab.tsx shrunk from 1387 to 109 lines — router now owns /apps/new
and /apps/:slug via AppDeploymentPage; list-only file retained.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 23:16:38 +02:00
hsiegeln
3a649f40cd ui(deploy): router blocker + DS dialog for unsaved edits
- Add deployedConfigSnapshot field to Deployment interface (mirrors server shape)
- Remove the Task 10.3 cast in handleRestore now that the type has the field
- New useUnsavedChangesBlocker hook (react-router useBlocker, v7.13.1)
- Wire AlertDialog into AppDeploymentPage for in-app navigation guard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 23:13:36 +02:00
hsiegeln
b1bdb88ea4 ui(deploy): compose page — save/redeploy/checkpoints wired end-to-end
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 23:10:55 +02:00
hsiegeln
0e4166bd5f ui(deploy): PrimaryActionButton + computeMode state-machine helper
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 23:05:46 +02:00
hsiegeln
42fb6c8b8c ui(deploy): useFormDirty hook for per-tab dirty markers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 23:05:22 +02:00
hsiegeln
1579f10a41 ui(deploy): DeploymentTab + flex-grow StartupLogPanel
DeploymentTab composes StatusCard, DeploymentProgress, StartupLogPanel,
and HistoryDisclosure for the latest deployment. StartupLogPanel gains an
optional className prop, drops the fixed maxHeight, and its .panel rule
uses flex-column + min-height:0 so a parent can drive its height.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 23:03:52 +02:00
hsiegeln
063a4a5532 ui(deploy): HistoryDisclosure with inline log expansion
Collapsible deployment history table (sorted newest-first) with
click-to-expand StartupLogPanel for any historical deployment row.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 23:03:02 +02:00
hsiegeln
98a7b7819f ui(deploy): StatusCard for Deployment tab
Status badge, replica count, URL, JAR/checksum grid, and stop/start
actions for the latest deployment. CSS added to AppDeploymentPage.module.css.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 23:02:44 +02:00
hsiegeln
e96c3cd0cf ui(deploy): Traces & Taps + Route Recording tabs with live banner
Ports the ConfigSubTab traces/taps and route recording content into
standalone tab components. Each write goes straight to live agents via
useUpdateApplicationConfig (apply='live'). A local draft state prevents
stale reads during the async flush. LiveBanner is rendered at the top of
both tabs to communicate the live-apply semantics.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 23:00:14 +02:00
hsiegeln
b7c0a225f5 ui(deploy): LiveBanner component for live-apply tabs
Adds a warning banner that communicates live-apply semantics (changes
bypass the Save/Redeploy cycle). Uses --warning-bg / --warning-border
DS tokens. CSS class .liveBanner added to AppDeploymentPage.module.css.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:59:11 +02:00
hsiegeln
f487e6caef ui(deploy): extract SensitiveKeysTab component
Pure presentational tab receiving SensitiveKeysFormState via value/onChange.
Calls useSensitiveKeys() internally to show global baseline (readonly).
Local useState for the new-key input buffer. Reuses skStyles from
SensitiveKeysPage.module.css for consistent pill/badge layout.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:57:02 +02:00
hsiegeln
bb06c4c689 ui(deploy): extract VariablesTab component
Pure presentational tab receiving VariablesFormState via value/onChange.
Rows use the new .envVarsList / .envVarRow CSS grid (1fr 2fr auto).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:56:31 +02:00
hsiegeln
5c48b780b2 ui(deploy): extract ResourcesTab component
Pure presentational tab receiving ResourcesFormState via value/onChange.
Local useState buffers for newPort/newNetwork keep the "add next item"
inputs isolated from form state. isProd prop gates the memory-reserve field.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:56:05 +02:00
hsiegeln
4f5a11f715 ui(deploy): extract MonitoringTab component
Pure presentational tab receiving MonitoringFormState via value/onChange.
Also adds shared config-tab styles to AppDeploymentPage.module.css
(configInline, toggleEnabled/Disabled, portPills, inputSizes, envVarsList/Row).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:55:25 +02:00
hsiegeln
cc193a1075 ui(deploy): add useDeploymentPageState orchestrator hook
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:53:06 +02:00
hsiegeln
08efdfa9c5 ui(deploy): Checkpoints disclosure (hides current deployment, flags pruned JARs)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:51:39 +02:00
hsiegeln
00c7c0cd71 ui(deploy): Identity & Artifact section with filename auto-derive
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:49:43 +02:00
hsiegeln
d067490f71 ui(deploy): add deriveAppName pure function + tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:46:52 +02:00
hsiegeln
52ff385b04 ui(api): add useDirtyState + apply=staged|live on useUpdateApplicationConfig
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:45:42 +02:00
hsiegeln
6052975750 ui(deploy): scaffold AppDeploymentPage + route /apps/new and /apps/:slug
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:43:54 +02:00
hsiegeln
0434299d53 api(schema): regenerate OpenAPI + schema.d.ts for deployment page
Picks up GET dirty-state, PUT config ?apply=staged|live, and
deployedConfigSnapshot on Deployment for the deployment config-diff UI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:42:10 +02:00
hsiegeln
97f25b4c7e test(deploy): register JavaTimeModule in DirtyStateCalculator unit test
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:38:57 +02:00
hsiegeln
6591f2fde3 api(apps): GET /apps/{slug}/dirty-state returns desired-vs-deployed diff
Wires DirtyStateCalculator behind an HTTP endpoint on AppController.
Adds findLatestSuccessfulByAppAndEnv to PostgresDeploymentRepository,
registers DirtyStateCalculator as a Spring bean (with ObjectMapper for
JavaTimeModule support), and covers all three scenarios with IT.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:35:35 +02:00
hsiegeln
24464c0772 core(deploy): recurse into nested diffs + unquote scalar values in DirtyStateCalculator
- compareJson now recurses when both nodes are ObjectNode, so nested maps
  (tracedProcessors, routeRecording, routeSamplingRates) produce deep paths
  like agentConfig.tracedProcessors.proc-1 instead of a blob diff
- Extract nodeToString helper: value nodes use asText() (strips JSON quotes),
  null becomes "(none)", arrays/objects get compact JSON
- Apply nodeToString in both diff-emission paths (top-level mismatch + leaf)
- Add three new tests: nullAgentConfigInSnapshot, nestedAgentField_reportsDeepPath,
  stringField_differenceValueIsUnquoted (8 tests total, all pass)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:25:04 +02:00
hsiegeln
e4ccce1e3b core(deploy): add DirtyStateCalculator + DirtyStateResult
Pure-logic dirty-state detection: compares desired JAR + agent config + container
config against the DeploymentConfigSnapshot from the last successful deployment.
Returns a structured DirtyStateResult with per-field differences. 5 unit tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:20:49 +02:00
hsiegeln
76352c0d6f test(config): tighten audit assertions + @DirtiesContext on ApplicationConfigControllerIT
- Add @DirtiesContext(AFTER_CLASS) so the SpyBean-forked context is torn
  down after the 6 tests finish, preventing permanent cache pollution
- Replace single-row queryForObject with queryForList + hasSize(1) in both
  audit tests so spurious extra rows will fail explicitly
- Assert auditCount == 0 in the 400 test to lock in the no-audit-on-bad-input invariant

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:18:44 +02:00
hsiegeln
e716dbf8ca test(config): verify audit action in staged/live config IT
Replace the misleading putConfig_staged_auditActionIsStagedAppConfig test
(which only checked pushResult.total == 0, a duplicate of _savesButDoesNotPush)
with two real audit-log assertions: one verifying "stage_app_config" is written
for apply=staged and a new companion test verifying "update_app_config" for the
live path. Uses jdbcTemplate to query audit_log directly (Option B).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:13:53 +02:00
hsiegeln
76129d407e api(config): ?apply=staged|live gates SSE push on PUT /apps/{slug}/config
When apply=staged, saves to DB only — no CONFIG_UPDATE dispatched to agents.
When apply=live (default, back-compat), preserves today's immediate-push behavior.
Unknown apply values return 400. Audit action is stage_app_config vs update_app_config.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:07:36 +02:00
hsiegeln
9b1240274d test(deploy): assert containerConfig round-trip + strict RUNNING in snapshot IT
Adds the missing containerConfig assertion to snapshot_isPopulated_whenDeploymentReachesRunning
(runtimeType + appPort entries), and tightens the await predicate from .isIn(RUNNING, DEGRADED)
to .isEqualTo(RUNNING) — the mock returns a healthy container so RUNNING is deterministic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 21:54:57 +02:00
hsiegeln
a79eafeaf4 runtime(deploy): capture config snapshot on RUNNING transition
Injects PostgresApplicationConfigRepository into DeploymentExecutor and
calls saveDeployedConfigSnapshot at the COMPLETE stage, before
markRunning. Snapshot contains jarVersionId, agentConfig (nullable),
and app.containerConfig. The FAILED catch path is left untouched so
snapshot stays null on failure. Verified by DeploymentSnapshotIT.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 21:51:00 +02:00
hsiegeln
9b851c4622 test(deploy): autowire repository in snapshot IT (JavaTimeModule-safe)
Replace manual `new PostgresDeploymentRepository(jdbcTemplate, new ObjectMapper())` with
`@Autowired PostgresDeploymentRepository repository` to use the Spring-managed bean whose
ObjectMapper has JavaTimeModule registered. Also removes the redundant isNotNull() assertion
whose work is done by the field-level assertions that follow.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 21:43:40 +02:00
hsiegeln
d3e86b9d77 storage(deploy): persist deployed_config_snapshot as JSONB
Wire SELECT_COLS, mapRow deserialization, and saveDeployedConfigSnapshot
update method. Adds PostgresDeploymentRepositoryIT with roundtrip,
null-default, and clear-to-null tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 21:39:04 +02:00
hsiegeln
7f9cfc7f18 core(deploy): add deployedConfigSnapshot field to Deployment model
Appends DeploymentConfigSnapshot deployedConfigSnapshot to the Deployment
record and adds a matching withDeployedConfigSnapshot wither. All
positional call sites (repository mapper, test fixture) updated to pass
null; Task 1.4 will wire real persistence and Task 1.5 will populate
the field on RUNNING transition.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 21:31:48 +02:00
hsiegeln
06fa7d832f core(deploy): type jarVersionId as UUID (match domain convention)
All other FKs to app_versions.id (e.g. Deployment.appVersionId) use UUID;
DeploymentConfigSnapshot.jarVersionId was incorrectly typed as String.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 21:29:26 +02:00
hsiegeln
d580b6e90c core(deploy): add DeploymentConfigSnapshot record
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 21:26:30 +02:00
hsiegeln
ff95187707 db(deploy): add deployments.deployed_config_snapshot column (V3)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 21:23:46 +02:00
hsiegeln
1a376eb25f plan(deploy): unified app deployment page implementation plan
13 phases, TDD-oriented: Flyway V3 snapshot column, staged/live config
write flag, dirty-state endpoint, regen OpenAPI, then the new React page
(Identity, Checkpoints, 7 tabs including the live-apply Traces+Taps and
Route Recording with banner), primary Save/Redeploy state machine,
router blocker, old view cleanup, rules docs, and a manual QA walkthrough.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:14:11 +02:00
hsiegeln
58ec67aef9 spec(deploy): unified app deployment page design
Single page at /apps/:slug (+ /apps/new in net-new mode) replacing the
CreateAppView/AppDetailView split. Save ↔ Redeploy state machine driven
by a deployment snapshot on the deployments table, agent-config writes
gain ?apply=staged|live, Identity & Artifact always visible, new
Deployment tab carries progress + startup log, and checkpoints restore
full prior state (JAR + config) from past successful deploys.

Concurrent-edit protection deferred to #147.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:02:50 +02:00
280 changed files with 33273 additions and 1813 deletions

View File

@@ -53,18 +53,18 @@ Env-scoped read-path controllers (`AlertController`, `AlertRuleController`, `Ale
### Env-scoped (user-facing data & config)
- `AppController``/api/v1/environments/{envSlug}/apps`. GET list / POST create / GET `{appSlug}` / DELETE `{appSlug}` / GET `{appSlug}/versions` / POST `{appSlug}/versions` (JAR upload) / PUT `{appSlug}/container-config`. App slug uniqueness is per-env (`(env, app_slug)` is the natural key). `CreateAppRequest` body has no env (path), validates slug regex.
- `DeploymentController``/api/v1/environments/{envSlug}/apps/{appSlug}/deployments`. GET list / POST create (body `{ appVersionId }`) / POST `{id}/stop` / POST `{id}/promote` (body `{ targetEnvironment: slug }` — target app slug must exist in target env) / GET `{id}/logs`.
- `ApplicationConfigController``/api/v1/environments/{envSlug}`. GET `/config` (list), GET/PUT `/apps/{appSlug}/config`, GET `/apps/{appSlug}/processor-routes`, POST `/apps/{appSlug}/config/test-expression`. PUT also pushes `CONFIG_UPDATE` to LIVE agents in this env.
- `AppController``/api/v1/environments/{envSlug}/apps`. GET list / POST create / GET `{appSlug}` / DELETE `{appSlug}` / GET `{appSlug}/versions` / POST `{appSlug}/versions` (JAR upload) / PUT `{appSlug}/container-config` / GET `{appSlug}/dirty-state` (returns `DirtyStateResponse{dirty, lastSuccessfulDeploymentId, differences}` — compares current JAR+config against last RUNNING deployment snapshot; dirty=true when no snapshot exists). App slug uniqueness is per-env (`(env, app_slug)` is the natural key). `CreateAppRequest` body has no env (path), validates slug regex. Injects `DirtyStateCalculator` bean (registered in `RuntimeBeanConfig`, requires `ObjectMapper` with `JavaTimeModule`).
- `DeploymentController``/api/v1/environments/{envSlug}/apps/{appSlug}/deployments`. GET list / POST create (body `{ appVersionId }`) / POST `{id}/stop` / POST `{id}/promote` (body `{ targetEnvironment: slug }` — target app slug must exist in target env) / GET `{id}/logs`. All lifecycle ops (`POST /` deploy, `POST /{id}/stop`, `POST /{id}/promote`) audited under `AuditCategory.DEPLOYMENT`. Action codes: `deploy_app`, `stop_deployment`, `promote_deployment`. Acting user resolved via the `user:` prefix-strip convention; both SUCCESS and FAILURE branches write audit rows. `created_by` (TEXT, nullable) populated from `SecurityContextHolder` and surfaced on the `Deployment` DTO.
- `ApplicationConfigController``/api/v1/environments/{envSlug}`. GET `/config` (list), GET/PUT `/apps/{appSlug}/config`, GET `/apps/{appSlug}/processor-routes`, POST `/apps/{appSlug}/config/test-expression`. PUT accepts `?apply=staged|live` (default `live`). `live` saves to DB and pushes `CONFIG_UPDATE` SSE to live agents in this env (existing behavior); `staged` saves to DB only, skipping the SSE push — used by the unified app deployment page. Audit action is `stage_app_config` for staged writes, `update_app_config` for live. Invalid `apply` values return 400.
- `AppSettingsController``/api/v1/environments/{envSlug}`. GET `/app-settings` (list), GET/PUT/DELETE `/apps/{appSlug}/settings`. ADMIN/OPERATOR only.
- `SearchController``/api/v1/environments/{envSlug}`. GET `/executions`, POST `/executions/search`, GET `/stats`, `/stats/timeseries`, `/stats/timeseries/by-app`, `/stats/timeseries/by-route`, `/stats/punchcard`, `/attributes/keys`, `/errors/top`.
- `LogQueryController` — GET `/api/v1/environments/{envSlug}/logs` (filters: source (multi, comma-split, OR-joined), level (multi, comma-split, OR-joined), application, agentId, exchangeId, logger, q, time range; sort asc/desc). Cursor-paginated, returns `{ data, nextCursor, hasMore, levelCounts }`; cursor is base64url of `"{timestampIso}|{insert_id_uuid}"` — same-millisecond tiebreak via the `insert_id` UUID column on `logs`.
- `SearchController``/api/v1/environments/{envSlug}`. GET `/executions`, POST `/executions/search`, GET `/stats`, `/stats/timeseries`, `/stats/timeseries/by-app`, `/stats/timeseries/by-route`, `/stats/punchcard`, `/attributes/keys`, `/errors/top`. GET `/executions` accepts repeat `attr` query params: `attr=order` (key-exists), `attr=order:47` (exact), `attr=order:4*` (wildcard — `*` maps to SQL LIKE `%`). First `:` splits key/value; later colons stay in the value. Invalid keys → 400. POST `/executions/search` accepts the same filters via `SearchRequest.attributeFilters` in the body.
- `LogQueryController` — GET `/api/v1/environments/{envSlug}/logs` (filters: source (multi, comma-split, OR-joined), level (multi, comma-split, OR-joined), application, agentId, exchangeId, logger, q, time range, instanceIds (multi, comma-split, AND-joined as WHERE instance_id IN (...) — used by the Checkpoint detail drawer to scope logs to a deployment's replicas); sort asc/desc). Cursor-paginated, returns `{ data, nextCursor, hasMore, levelCounts }`; cursor is base64url of `"{timestampIso}|{insert_id_uuid}"` — same-millisecond tiebreak via the `insert_id` UUID column on `logs`.
- `RouteCatalogController` — GET `/api/v1/environments/{envSlug}/routes` (merged route catalog from registry + ClickHouse; env filter unconditional).
- `RouteMetricsController` — GET `/api/v1/environments/{envSlug}/routes/metrics`, GET `/api/v1/environments/{envSlug}/routes/metrics/processors`.
- `AgentListController` — GET `/api/v1/environments/{envSlug}/agents` (registered agents with runtime metrics, filtered to env).
- `AgentEventsController` — GET `/api/v1/environments/{envSlug}/agents/events` (lifecycle events; cursor-paginated, returns `{ data, nextCursor, hasMore }`; order `(timestamp DESC, insert_id DESC)`; cursor is base64url of `"{timestampIso}|{insert_id_uuid}"``insert_id` is a stable UUID column used as a same-millisecond tiebreak).
- `AgentMetricsController` — GET `/api/v1/environments/{envSlug}/agents/{agentId}/metrics` (JVM/Camel metrics). Rejects cross-env agents (404) as defence-in-depth.
- `DiagramRenderController` — GET `/api/v1/environments/{envSlug}/apps/{appSlug}/routes/{routeId}/diagram` (env-scoped lookup). Also GET `/api/v1/diagrams/{contentHash}/render` (flat — content hashes are globally unique).
- `DiagramRenderController` — GET `/api/v1/environments/{envSlug}/apps/{appSlug}/routes/{routeId}/diagram` returns the most recent diagram for (app, env, route) via `DiagramStore.findLatestContentHashForAppRoute`. Registry-independent — routes whose publishing agents were removed still resolve. Also GET `/api/v1/diagrams/{contentHash}/render` (flat — content hashes are globally unique), the point-in-time path consumed by the exchange viewer via `ExecutionDetail.diagramContentHash`.
- `AlertRuleController``/api/v1/environments/{envSlug}/alerts/rules`. GET list / POST create / GET `{id}` / PUT `{id}` / DELETE `{id}` / POST `{id}/enable` / POST `{id}/disable` / POST `{id}/render-preview` / POST `{id}/test-evaluate`. OPERATOR+ for mutations, VIEWER+ for reads. CRITICAL: attribute keys in `ExchangeMatchCondition.filter.attributes` are validated at rule-save time against `^[a-zA-Z0-9._-]+$` — they are later inlined into ClickHouse SQL. `AgentLifecycleCondition` is allowlist-only — the `AgentLifecycleEventType` enum (REGISTERED / RE_REGISTERED / DEREGISTERED / WENT_STALE / WENT_DEAD / RECOVERED) plus the record compact ctor (non-empty `eventTypes`, `withinSeconds ≥ 1`) do the validation; custom agent-emitted event types are tracked in backlog issue #145. Webhook validation: verifies `outboundConnectionId` exists and `isAllowedInEnvironment`. Null notification templates default to `""` (NOT NULL constraint). Audit: `ALERT_RULE_CHANGE`.
- `AlertController``/api/v1/environments/{envSlug}/alerts`. GET list (inbox filtered by userId/groupIds/roleNames via `InAppInboxQuery`; optional multi-value `state`, `severity`, tri-state `acked`, tri-state `read` query params; soft-deleted rows always excluded) / GET `/unread-count` / GET `{id}` / POST `{id}/ack` / POST `{id}/read` / POST `/bulk-read` / POST `/bulk-ack` (VIEWER+) / DELETE `{id}` (OPERATOR+, soft-delete) / POST `/bulk-delete` (OPERATOR+) / POST `{id}/restore` (OPERATOR+, clears `deleted_at`). `requireLiveInstance` helper returns 404 on soft-deleted rows; `restore` explicitly fetches regardless of `deleted_at`. `BulkIdsRequest` is the shared body for bulk-read/ack/delete (`{ instanceIds }`). `AlertDto` includes `readAt`; `deletedAt` is intentionally NOT on the wire. Inbox SQL: `? = ANY(target_user_ids) OR target_group_ids && ? OR target_role_names && ?` — requires at least one matching target (no broadcast concept).
- `AlertSilenceController``/api/v1/environments/{envSlug}/alerts/silences`. GET list / POST create / DELETE `{id}`. 422 if `endsAt <= startsAt`. OPERATOR+ for mutations, VIEWER+ for list. Audit: `ALERT_SILENCE_CHANGE`.
@@ -102,13 +102,15 @@ Env-scoped read-path controllers (`AlertController`, `AlertRuleController`, `Ale
- `OutboundConnectionAdminController``/api/v1/admin/outbound-connections`. GET list / POST create / GET `{id}` / PUT `{id}` / DELETE `{id}` / POST `{id}/test` / GET `{id}/usage`. RBAC: list/get/usage ADMIN|OPERATOR; mutations + test ADMIN.
- `SensitiveKeysAdminController` — GET/PUT `/api/v1/admin/sensitive-keys`. GET returns 200 or 204 if not configured. PUT accepts `{ keys: [...] }` with optional `?pushToAgents=true`. Fan-out iterates every distinct `(application, environment)` slice — intentional global baseline + per-env overrides.
- `ClaimMappingAdminController` — CRUD `/api/v1/admin/claim-mappings`, POST `/test`.
- `LicenseAdminController` — GET/POST `/api/v1/admin/license`.
- `LicenseAdminController` — GET/POST `/api/v1/admin/license`. ADMIN only. GET returns `{state, invalidReason, envelope, lastValidatedAt?}` — the raw token is deliberately omitted; only the parsed `LicenseInfo` envelope is exposed. POST delegates to `LicenseService.install(token, userId, "api")` (acting userId resolved via the `user:` prefix-strip convention) — install/replace/reject all flow through `LicenseService` so audit, persistence, and `LicenseChangedEvent` publishing are uniform.
- `LicenseUsageController` — GET `/api/v1/admin/license/usage`. Returns license `state`, `expiresAt`/`daysRemaining`/`gracePeriodDays`/`tenantId`/`label`/`lastValidatedAt`, the `LicenseMessageRenderer.forState(...)` message, and a `limits[]` array (`{key, current, cap, source}`) covering every effective-limits key. `source` is `"license"` when the cap came from the license override map, `"default"` otherwise. `max_agents` reads from `AgentRegistryService.liveCount()`; all other counts come from `LicenseUsageReader.snapshot()`.
- `ThresholdAdminController` — CRUD `/api/v1/admin/thresholds`.
- `AuditLogController` — GET `/api/v1/admin/audit`.
- `RbacStatsController` — GET `/api/v1/admin/rbac/stats`.
- `UsageAnalyticsController` — GET `/api/v1/admin/usage` (ClickHouse `usage_events`).
- `ClickHouseAdminController` — GET `/api/v1/admin/clickhouse/**` (conditional on `infrastructureendpoints` flag).
- `DatabaseAdminController` — GET `/api/v1/admin/database/**` (conditional on `infrastructureendpoints` flag).
- `ServerMetricsAdminController``/api/v1/admin/server-metrics/**`. GET `/catalog`, GET `/instances`, POST `/query`. Generic read API over the `server_metrics` ClickHouse table so SaaS dashboards don't need direct CH access. Delegates to `ServerMetricsQueryStore` (impl `ClickHouseServerMetricsQueryStore`). Visibility matches ClickHouse/Database admin: `@ConditionalOnProperty(infrastructureendpoints, matchIfMissing=true)` + class-level `@PreAuthorize("hasRole('ADMIN')")`. Validation: metric/tag regex `^[a-zA-Z0-9._]+$`, statistic regex `^[a-z_]+$`, `to - from ≤ 31 days`, stepSeconds ∈ [10, 3600], response capped at 500 series. `IllegalArgumentException` → 400. `/query` supports `raw` + `delta` modes (delta does per-`server_instance_id` positive-clipped differences, then aggregates across instances). Derived `statistic=mean` for timers computes `sum(total|total_time)/sum(count)` per bucket.
### Other (flat)
@@ -118,10 +120,10 @@ Env-scoped read-path controllers (`AlertController`, `AlertRuleController`, `Ale
## runtime/ — Docker orchestration
- `DockerRuntimeOrchestrator` — implements RuntimeOrchestrator; Docker Java client (zerodep transport), container lifecycle
- `DeploymentExecutor`@Async staged deploy: PRE_FLIGHT -> PULL_IMAGE -> CREATE_NETWORK -> START_REPLICAS -> HEALTH_CHECK -> SWAP_TRAFFIC -> COMPLETE. Container names are `{tenantId}-{envSlug}-{appSlug}-{replicaIndex}` (globally unique on Docker daemon). Sets per-replica `CAMELEER_AGENT_INSTANCEID` env var to `{envSlug}-{appSlug}-{replicaIndex}`.
- `DeploymentExecutor`@Async staged deploy: PRE_FLIGHT -> PULL_IMAGE -> CREATE_NETWORK -> START_REPLICAS -> HEALTH_CHECK -> SWAP_TRAFFIC -> COMPLETE. Container names are `{tenantId}-{envSlug}-{appSlug}-{replicaIndex}-{generation}`, where `generation` is the first 8 chars of the deployment UUID — old and new replicas coexist during a blue/green swap. Per-replica `CAMELEER_AGENT_INSTANCEID` env var is `{envSlug}-{appSlug}-{replicaIndex}-{generation}`. Branches on `DeploymentStrategy.fromWire(config.deploymentStrategy())`: **blue-green** (default) starts all N → waits for all healthy → stops old (partial health = FAILED, preserves old untouched); **rolling** replaces replicas one at a time with rollback only for in-flight new containers (already-replaced old stay stopped; un-replaced old keep serving). DEGRADED is now only set by `DockerEventMonitor` post-deploy, never by the executor. **License compute caps**: at PRE_FLIGHT (after `ConfigMerger.resolve`, before image pull / container creation) the executor consults `LicenseUsageReader.computeUsage()` (PG aggregate over non-stopped deployments) and runs three `LicenseEnforcer.assertWithinCap(...)` checks for `max_total_cpu_millis`, `max_total_memory_mb`, and `max_total_replicas`. A `LicenseCapExceededException` propagates to the surrounding `try/catch` which marks the deployment FAILED with the cap message in `deployments.error_message`.
- `DockerNetworkManager` — ensures bridge networks (cameleer-traefik, cameleer-env-{slug}), connects containers
- `DockerEventMonitor` — persistent Docker event stream listener (die, oom, start, stop), updates deployment status
- `TraefikLabelBuilder` — generates Traefik Docker labels for path-based or subdomain routing. Also emits `cameleer.replica` and `cameleer.instance-id` labels per container for labels-first identity.
- `TraefikLabelBuilder` — generates Traefik Docker labels for path-based or subdomain routing. Per-container identity labels: `cameleer.replica` (index), `cameleer.generation` (deployment-scoped 8-char id — for Prometheus/Grafana deploy-boundary annotations), `cameleer.instance-id` (`{envSlug}-{appSlug}-{replicaIndex}-{generation}`). Router/service label keys are generation-agnostic so load balancing spans old + new replicas during a blue/green overlap.
- `PrometheusLabelBuilder` — generates Prometheus Docker labels (`prometheus.scrape/path/port`) per runtime type for `docker_sd_configs` auto-discovery
- `ContainerLogForwarder` — streams Docker container stdout/stderr to ClickHouse with `source='container'`. One follow-stream thread per container, batches lines every 2s/50 lines via `ClickHouseLogStore.insertBufferedBatch()`. 60-second max capture timeout.
- `DisabledRuntimeOrchestrator` — no-op when runtime not enabled
@@ -129,11 +131,13 @@ Env-scoped read-path controllers (`AlertController`, `AlertRuleController`, `Ale
## metrics/ — Prometheus observability
- `ServerMetrics` — centralized business metrics: gauges (agents by state, SSE connections, buffer depths), counters (ingestion drops, agent transitions, deployment outcomes, auth failures), timers (flush duration, deployment duration). Exposed via `/api/v1/prometheus`.
- `ServerInstanceIdConfig``@Configuration`, exposes `@Bean("serverInstanceId") String`. Resolution precedence: `cameleer.server.instance-id` property → `HOSTNAME` env → `InetAddress.getLocalHost()` → random UUID. Fixed at boot; rotates across restarts so counters restart cleanly.
- `ServerMetricsSnapshotScheduler``@Scheduled(fixedDelayString = "${cameleer.server.self-metrics.interval-ms:60000}")`. Walks `MeterRegistry.getMeters()` each tick, emits one `ServerMetricSample` per `Measurement` (Timer/DistributionSummary produce multiple rows per meter — one per Micrometer `Statistic`). Skips non-finite values; logs and swallows store failures. Disabled via `cameleer.server.self-metrics.enabled=false` (`@ConditionalOnProperty`). Write-only — no query endpoint yet; inspect via `/api/v1/admin/clickhouse/query`.
## storage/ — PostgreSQL repositories (JdbcTemplate)
- `PostgresAppRepository`, `PostgresAppVersionRepository`, `PostgresEnvironmentRepository`
- `PostgresDeploymentRepository` — includes JSONB replica_states, deploy_stage, findByContainerId
- `PostgresDeploymentRepository` — includes JSONB replica_states, deploy_stage, findByContainerId. Also carries `deployed_config_snapshot` JSONB (Flyway V3) populated by `DeploymentExecutor` via `saveDeployedConfigSnapshot(UUID, DeploymentConfigSnapshot)` on successful RUNNING transition. Consumed by `DirtyStateCalculator` for the `/apps/{slug}/dirty-state` endpoint and by the UI for checkpoint restore.
- `PostgresUserRepository`, `PostgresRoleRepository`, `PostgresGroupRepository`
- `PostgresAuditRepository`, `PostgresOidcConfigRepository`, `PostgresClaimMappingRepository`, `PostgresSensitiveKeysRepository`
- `PostgresAppSettingsRepository`, `PostgresApplicationConfigRepository`, `PostgresThresholdRepository`. Both `app_settings` and `application_config` are env-scoped (PK `(app_id, environment)` / `(application, environment)`); finders take `(app, env)` — no env-agnostic variants.
@@ -145,6 +149,8 @@ Env-scoped read-path controllers (`AlertController`, `AlertRuleController`, `Ale
- `ClickHouseDiagramStore`, `ClickHouseAgentEventRepository`
- `ClickHouseUsageTracker` — usage_events for billing
- `ClickHouseRouteCatalogStore` — persistent route catalog with first_seen cache, warm-loaded on startup
- `ClickHouseServerMetricsStore` — periodic dumps of the server's own Micrometer registry into the `server_metrics` table. Tenant-stamped (bound at the scheduler, not the bean); no `environment` column (server straddles envs). Batch-insert via `JdbcTemplate.batchUpdate` with `Map(String, String)` tag binding. Written by `ServerMetricsSnapshotScheduler`.
- `ClickHouseServerMetricsQueryStore` — read side of `server_metrics` for dashboards. Implements `ServerMetricsQueryStore`. `catalog(from,to)` returns name+type+statistics+tagKeys, `listInstances(from,to)` returns server_instance_ids with first/last seen, `query(request)` builds bucketed time-series with `raw` or `delta` mode and supports a derived `mean` statistic for timers. All identifier inputs regex-validated; tenant_id always bound; max range 31 days; series count capped at 500. Exposed via `ServerMetricsAdminController`.
## search/ — ClickHouse search and log stores
@@ -196,10 +202,27 @@ Env-scoped read-path controllers (`AlertController`, `AlertRuleController`, `Ale
- `dto/OutboundConnectionTestResult` — result of POST `/{id}/test`: status, latencyMs, responseSnippet (first 512 chars), tlsProtocol/cipherSuite/peerCertSubject (protocol is "TLS" stub; enriched in Plan 02 follow-up), error (nullable).
- `config/OutboundBeanConfig` — registers `OutboundConnectionRepository`, `SecretCipher`, `OutboundConnectionService` beans.
## license/ — License enforcement & lifecycle
- `LicenseService` — install / replace / revalidate mediator. `install(token, installedBy, source)` validates via `LicenseValidator`, on failure marks the gate INVALID + audits `reject_license` + publishes `LicenseChangedEvent` and rethrows; on success persists via `LicenseRepository.upsert(...)`, mutates `LicenseGate`, audits `install_license` or `replace_license` (detects existing row), and publishes `LicenseChangedEvent`. `loadInitial(envToken, fileToken)` boot precedence env > file > DB; ABSENT publishes a `LicenseChangedEvent(ABSENT, null)`. `revalidate()` re-runs validation against the persisted token, on success bumps `last_validated_at`; on failure marks INVALID and audits `revalidate_license` FAILURE. `getTenantId()` exposes the tenant for downstream lookups.
- `LicenseRepository` — interface in `app/license`. `Optional<LicenseRecord> findByTenantId(String)`, `void upsert(LicenseRecord)`, `int touchValidated(String tenantId, Instant)`, `int delete(String)`.
- `LicenseRecord` — record persisted in PG `license` table: `(String tenantId, String token, UUID licenseId, Instant installedAt, String installedBy, Instant expiresAt, Instant lastValidatedAt)`.
- `PostgresLicenseRepository` — JdbcTemplate impl of `LicenseRepository`. Targets PG `license` table (V5). Upsert via `INSERT ... ON CONFLICT (tenant_id) DO UPDATE`.
- `LicenseChangedEvent` — Spring application event: `(LicenseState state, LicenseInfo current)`. Published on every install / replace / revalidate / boot-time ABSENT path so downstream listeners (retention policy, metrics, etc.) react uniformly.
- `LicenseEnforcer``@Component`. `assertWithinCap(String limitKey, long currentUsage, long requestedDelta)` consults `LicenseGate.getEffectiveLimits()`. On overflow increments `cameleer_license_cap_rejections_total{limit=...}`, emits an `AuditCategory.LICENSE / cap_exceeded` audit row when `AuditService` is wired (try/catch + log.warn so audit-write failures don't suppress the 403), and throws `LicenseCapExceededException`. Unknown limit keys propagate `IllegalArgumentException` from `LicenseLimits.get(...)` (programmer error, not a 403).
- `LicenseUsageReader``@Component` over PG. `snapshot()` returns a `Map<String,Long>` of (max_environments, max_apps, max_users, max_outbound_connections, max_alert_rules, max_total_cpu_millis, max_total_memory_mb, max_total_replicas) from PG row counts and a SUM over non-stopped deployments' `deployed_config_snapshot.containerConfig` (replicas × cpuLimit / memoryLimitMb). `computeUsage()` returns the typed `ComputeUsage(cpuMillis, memoryMb, replicas)` tuple consumed by `DeploymentExecutor` PRE_FLIGHT cap checks. `agentCount(int)` echoes a registry-supplied live count (registry is in-memory; not stored in PG).
- `LicenseCapExceededException` — typed `RuntimeException(limitKey, current, cap)` with accessors. Mapped to HTTP 403 by `LicenseExceptionAdvice`.
- `LicenseExceptionAdvice``@ControllerAdvice` mapping `LicenseCapExceededException` → 403 with body `{error:"license cap reached", limit, current, cap, state, message}` where `message` is `LicenseMessageRenderer.forCap(state, info, limit, current, cap, invalidReason)`.
- `LicenseMessageRenderer` — pure formatter (utility class, no DI). `forCap(state, info, limit, current, cap[, invalidReason])` per-state human messages for cap-rejection responses; `forState(state, info[, invalidReason])` shorter state-only messages for the `/usage` endpoint and metrics surfaces.
- `RetentionPolicyApplier``@EventListener(LicenseChangedEvent.class) @Async`. For each environment × table in the static `SPECS` list (`executions`, `processor_executions`, `logs`, `agent_metrics`, `agent_events`) computes `effective = min(licenseCap, env.configuredRetentionDays)` and emits `ALTER TABLE <t> MODIFY TTL toDateTime(<col>) + INTERVAL <n> DAY DELETE WHERE environment = '<slug>'`. ClickHouse failures are logged and swallowed (best-effort; never propagates to the originating license install/revalidate). `route_diagrams` (no TTL clause) and `server_metrics` (no environment column) are intentionally excluded.
- `LicenseRevalidationJob``@Component`. `@Scheduled(cron = "0 0 3 * * *")` daily revalidation; `@EventListener(ApplicationReadyEvent.class) @Async` 60-second post-startup tick to catch ABSENT→ACTIVE when a license was inserted between server starts. Both paths call `LicenseService.revalidate()` and swallow scheduler-thread crashes.
- `LicenseMetrics``@Component`. Registers Micrometer gauges: `cameleer_license_state{state=...}` (one-hot per `LicenseState`), `cameleer_license_days_remaining` (negative when ABSENT/INVALID), `cameleer_license_last_validated_age_seconds` (0 when no DB row). Refreshed eagerly on `LicenseChangedEvent` via `@EventListener` and lazily every 60s via `@Scheduled(fixedDelay = 60_000)`.
## config/ — Spring beans
- `RuntimeOrchestratorAutoConfig` — conditional Docker/Disabled orchestrator + NetworkManager + EventMonitor
- `RuntimeBeanConfig` — DeploymentExecutor, AppService, EnvironmentService
- `RuntimeBeanConfig` — DeploymentExecutor, AppService, EnvironmentService. Wires `CreateGuard` instances per service from `LicenseEnforcer.assertWithinCap(...)` so creation paths (Environment, App, Agent) consult license caps without core depending on the app module.
- `SecurityBeanConfig` — JwtService, Ed25519, BootstrapTokenValidator
- `StorageBeanConfig` — all repositories
- `ClickHouseConfig` — ClickHouse JdbcTemplate, schema initializer
- `LicenseBeanConfig` — license bean topology in dependency order: `LicenseGate``LicenseValidator` (when `cameleer.server.license.publickey` is unset, an always-failing override is returned so any loaded token still routes through `install()` and is audited as INVALID, never silently dropped) → `LicenseService``LicenseBootLoader` (`@PostConstruct` drives `loadInitial(envToken, fileToken)` once the context is ready; resolution order env var > license file > persisted DB row).

View File

@@ -8,8 +8,11 @@ paths:
# CI/CD & Deployment
- CI workflow: `.gitea/workflows/ci.yml` — build -> docker -> deploy on push to main or feature branches
- CI workflow: `.gitea/workflows/ci.yml` — build -> docker -> deploy on push to main or feature branches. `paths-ignore` skips the whole pipeline for docs-only / `.planning/` / `.claude/` / `*.md` changes (push and PR triggers).
- Build step skips integration tests (`-DskipITs`) — Testcontainers needs Docker daemon
- Build caches (parallel `actions/cache@v4` steps in the `build` job): `~/.m2/repository` (key on all `pom.xml`), `~/.npm` (key on `ui/package-lock.json`), `ui/node_modules/.vite` (key on `ui/package-lock.json` + `ui/vite.config.ts`). UI install uses `npm ci --prefer-offline --no-audit --fund=false` so the npm cache is the primary source.
- Maven build performance (set in `pom.xml` and `cameleer-server-app/pom.xml`): `useIncrementalCompilation=true` on the compiler plugin; Surefire uses `forkCount=1C` + `reuseForks=true` (one JVM per CPU core, reused across test classes); Failsafe keeps `forkCount=1` + `reuseForks=true`. Unit tests must not rely on per-class JVM isolation.
- UI build script (`ui/package.json`): `build` is `vite build` only — the type-check pass was split out into `npm run typecheck` (run separately when you want a full `tsc --noEmit` sweep).
- Docker: multi-stage build (`Dockerfile`), `$BUILDPLATFORM` for native Maven on ARM64 runner, amd64 runtime. `docker-entrypoint.sh` imports `/certs/ca.pem` into JVM truststore before starting the app (supports custom CAs for OIDC discovery without `CAMELEER_SERVER_SECURITY_OIDCTLSSKIPVERIFY`).
- `REGISTRY_TOKEN` build arg required for `cameleer-common` dependency resolution
- Registry: `gitea.siegeln.net/cameleer/cameleer-server` (container images)

View File

@@ -26,34 +26,47 @@ paths:
- `App` — record: id, environmentId, slug, displayName, containerConfig (JSONB)
- `AppVersion` — record: id, appId, version, jarPath, detectedRuntimeType, detectedMainClass
- `Environment` — record: id, slug, displayName, production, enabled, defaultContainerConfig, jarRetentionCount, color, createdAt. `color` is one of the 8 preset palette values validated by `EnvironmentColor.VALUES` and CHECK-constrained in PostgreSQL (V2 migration).
- `Environment` — record: id, slug, displayName, production, enabled, defaultContainerConfig, jarRetentionCount, color, createdAt, executionRetentionDays, logRetentionDays, metricRetentionDays. `color` is one of the 8 preset palette values validated by `EnvironmentColor.VALUES` and CHECK-constrained in PostgreSQL (V2 migration). The 3 retention day fields (V5) are `int`-typed (not nullable, since unlimited has no use-case), default to 1 day per the V5 `NOT NULL DEFAULT 1`, validated >= 1 in the canonical constructor.
- `EnvironmentColor` — constants: `DEFAULT = "slate"`, `VALUES = {slate,red,amber,green,teal,blue,purple,pink}`, `isValid(String)`.
- `Deployment` — record: id, appId, appVersionId, environmentId, status, targetState, deploymentStrategy, replicaStates (JSONB), deployStage, containerId, containerName
- `DeploymentStatus` — enum: STOPPED, STARTING, RUNNING, DEGRADED, STOPPING, FAILED
- `Deployment` — record: id, appId, appVersionId, environmentId, status, targetState, deploymentStrategy, replicaStates (JSONB), deployStage, containerId, containerName, createdBy (String, user_id reference; nullable for pre-V4 historical rows)
- `DeploymentStatus` — enum: STOPPED, STARTING, RUNNING, DEGRADED, STOPPING, FAILED. `DEGRADED` is reserved for post-deploy drift (a replica died after RUNNING); `DeploymentExecutor` now marks partial-healthy deploys FAILED, not DEGRADED.
- `DeployStage` — enum: PRE_FLIGHT, PULL_IMAGE, CREATE_NETWORK, START_REPLICAS, HEALTH_CHECK, SWAP_TRAFFIC, COMPLETE
- `DeploymentService` — createDeployment (deletes terminal deployments first), markRunning, markFailed, markStopped
- `DeploymentStrategy` — enum: BLUE_GREEN, ROLLING. Stored on `ResolvedContainerConfig.deploymentStrategy` as kebab-case string (`"blue-green"` / `"rolling"`). `fromWire(String)` is the only conversion entry point; unknown/null inputs fall back to BLUE_GREEN so the executor dispatch site never null-checks or throws.
- `DeploymentService` — createDeployment (calls `deleteFailedByAppAndEnvironment` first so FAILED rows don't pile up; STOPPED rows are preserved as restorable checkpoints), markRunning, markFailed, markStopped
- `RuntimeType` — enum: AUTO, SPRING_BOOT, QUARKUS, PLAIN_JAVA, NATIVE
- `RuntimeDetector` — probes JAR files at upload time: detects runtime from manifest Main-Class (Spring Boot loader, Quarkus entry point, plain Java) or native binary (non-ZIP magic bytes)
- `ContainerRequest` — record: 20 fields for Docker container creation (includes runtimeType, customArgs, mainClass)
- `ContainerStatus` — record: state, running, exitCode, error
- `ResolvedContainerConfig` — record: typed config with memoryLimitMb, memoryReserveMb, cpuRequest, cpuLimit, appPort, exposedPorts, customEnvVars, stripPathPrefix, sslOffloading, routingMode, routingDomain, serverUrl, replicas, deploymentStrategy, routeControlEnabled, replayEnabled, runtimeType, customArgs, extraNetworks
- `ResolvedContainerConfig` — record: typed config with memoryLimitMb, memoryReserveMb, cpuRequest, cpuLimit, appPort, exposedPorts, customEnvVars, stripPathPrefix, sslOffloading, routingMode, routingDomain, serverUrl, replicas, deploymentStrategy, routeControlEnabled, replayEnabled, runtimeType, customArgs, extraNetworks, externalRouting (default `true`; when `false`, `TraefikLabelBuilder` strips all `traefik.*` labels so the container is not publicly routed), certResolver (server-wide, sourced from `CAMELEER_SERVER_RUNTIME_CERTRESOLVER`; when blank the `tls.certresolver` label is omitted — use for dev installs with a static TLS store)
- `RoutingMode` — enum for routing strategies
- `ConfigMerger` — pure function: resolve(globalDefaults, envConfig, appConfig) -> ResolvedContainerConfig
- `RuntimeOrchestrator` — interface: startContainer, stopContainer, getContainerStatus, getLogs, startLogCapture, stopLogCapture
- `AppRepository`, `AppVersionRepository`, `EnvironmentRepository`, `DeploymentRepository` — repository interfaces
- `AppService`, `EnvironmentService` — domain services
- `CreateGuard``@FunctionalInterface`. `void check(long current)` — implementations throw to abort creation. `NOOP` constant is the default. Consulted by `EnvironmentService.create`, `AppService.createApp`, and `AgentRegistryService.register` so license caps can be enforced from the app module without leaking Spring or app-only types into core. Wired in `LicenseBeanConfig` to a `LicenseEnforcer.assertWithinCap(...)` call per limit key.
## license/ — License domain (signed-token tier system)
- `LicenseInfo` — record: `(UUID licenseId, String tenantId, String label, Map<String,Integer> limits, Instant issuedAt, Instant expiresAt, int gracePeriodDays)`. `isExpired()` true once `now > expiresAt + gracePeriodDays`; `isAfterRawExpiry()` true once `now > expiresAt`. Constructed via `LicenseValidator`; canonical ctor null-checks all required fields and rejects blank tenantId / negative grace.
- `LicenseLimits` — typed limits container backed by `Map<String,Integer>`. `defaultsOnly()` returns the `DefaultTierLimits.DEFAULTS` view; `mergeOverDefaults(overrides)` produces the license-overrides UNION default tier. `get(String key)` returns the cap; throws `IllegalArgumentException` for unknown keys (programmer error). `isDefaultSourced(key, license)` reports whether a key fell through to the default tier.
- `DefaultTierLimits` — immutable `LinkedHashMap` of constants for the no-license fallback tier: `max_environments=1, max_apps=3, max_agents=5, max_users=3, max_outbound_connections=1, max_alert_rules=2, max_total_cpu_millis=2000, max_total_memory_mb=2048, max_total_replicas=5, max_execution_retention_days=1, max_log_retention_days=1, max_metric_retention_days=1, max_jar_retention_count=3`.
- `LicenseValidator` — verifies signed token. Constructor `(String publicKeyBase64, String expectedTenantId)` decodes an X.509 Ed25519 public key. `validate(String token)` splits `payload.signature`, verifies the Ed25519 signature, parses the JSON payload, enforces `tenantId == expectedTenantId`, and returns `LicenseInfo`. Throws `SecurityException` on signature mismatch / `IllegalArgumentException` on parse failure / expired payload.
- `LicenseGate` — runtime state holder (thread-safe via `AtomicReference<Snapshot>`). `getCurrent()` returns the current `LicenseInfo` (null when ABSENT/INVALID); `getState()` delegates to `LicenseStateMachine.classify(...)`; `getEffectiveLimits()` returns license-overrides UNION defaults in `ACTIVE`/`GRACE`, defaults-only otherwise. `getInvalidReason()`, `load(LicenseInfo)`, `markInvalid(String reason)`, `clear()` are the mutators. `getLimit(key, defaultValue)` shorthand swallows unknown-key errors.
- `LicenseStateMachine` — pure classifier. `classify(LicenseInfo, String invalidReason)` returns `INVALID` if a reason is set, `ABSENT` if no license, `ACTIVE` if `now <= expiresAt`, `GRACE` if expired but within grace window, `EXPIRED` otherwise.
- `LicenseState` — enum: `ABSENT, ACTIVE, GRACE, EXPIRED, INVALID`.
## search/ — Execution search and stats
- `SearchService` — search, count, stats, statsForApp, statsForRoute, timeseries, timeseriesForApp, timeseriesForRoute, timeseriesGroupedByApp, timeseriesGroupedByRoute, slaCompliance, slaCountsByApp, slaCountsByRoute, topErrors, activeErrorTypes, punchcard, distinctAttributeKeys. `statsForRoute`/`timeseriesForRoute` take `(routeId, applicationId)` — app filter is applied to `stats_1m_route`.
- `SearchRequest` / `SearchResult` — search DTOs
- `SearchRequest` / `SearchResult` — search DTOs. `SearchRequest.attributeFilters: List<AttributeFilter>` carries structured facet filters for execution attributes — key-only (exists), exact (key=value), or wildcard (`*` in value). The 21-arg legacy ctor is preserved for call-site churn; the compact ctor normalises null → `List.of()`.
- `AttributeFilter(key, value)` — record with key regex `^[a-zA-Z0-9._-]+$` (inlined into SQL, same constraint as alerting), `value == null` means key-exists, `value` containing `*` becomes a SQL LIKE pattern via `toLikePattern()`.
- `ExecutionStats`, `ExecutionSummary` — stats aggregation records
- `StatsTimeseries`, `TopError` — timeseries and error DTOs
- `LogSearchRequest` / `LogSearchResponse` — log search DTOs. `LogSearchRequest.sources` / `levels` are `List<String>` (null-normalized, multi-value OR); `cursor` + `limit` + `sort` drive keyset pagination. Response carries `nextCursor` + `hasMore` + per-level `levelCounts`.
## storage/ — Storage abstractions
- `ExecutionStore`, `MetricsStore`, `MetricsQueryStore`, `StatsStore`, `DiagramStore`, `RouteCatalogStore`, `SearchIndex`, `LogIndex` — interfaces
- `ExecutionStore`, `MetricsStore`, `MetricsQueryStore`, `StatsStore`, `DiagramStore`, `RouteCatalogStore`, `SearchIndex`, `LogIndex` — interfaces. `DiagramStore.findLatestContentHashForAppRoute(appId, routeId, env)` resolves the latest diagram by (app, env, route) without consulting the agent registry, so routes whose publishing agents were removed between app versions still resolve. `findContentHashForRoute(route, instance)` is retained for the ingestion path that stamps a per-execution `diagramContentHash` at ingest time (point-in-time link from `ExecutionDetail`/`ExecutionSummary`).
- `RouteCatalogEntry` — record: applicationId, routeId, environment, firstSeen, lastSeen
- `LogEntryResult` — log query result record
- `model/``ExecutionDocument`, `MetricTimeSeries`, `MetricsSnapshot`
@@ -79,7 +92,7 @@ paths:
- `AppSettings`, `AppSettingsRepository` — per-app-per-env settings config and persistence. Record carries `(applicationId, environment, …)`; repository methods are `findByApplicationAndEnvironment`, `findByEnvironment`, `save`, `delete(appId, env)`. `AppSettings.defaults(appId, env)` produces a default instance scoped to an environment.
- `ThresholdConfig`, `ThresholdRepository` — alerting threshold config and persistence
- `AuditService` — audit logging facade
- `AuditRecord`, `AuditResult`, `AuditCategory` (enum: `INFRA, AUTH, USER_MGMT, CONFIG, RBAC, AGENT, OUTBOUND_CONNECTION_CHANGE, OUTBOUND_HTTP_TRUST_CHANGE`), `AuditRepository` — audit trail records and persistence
- `AuditRecord`, `AuditResult`, `AuditCategory` (enum: `INFRA, AUTH, USER_MGMT, CONFIG, RBAC, AGENT, OUTBOUND_CONNECTION_CHANGE, OUTBOUND_HTTP_TRUST_CHANGE, ALERT_RULE_CHANGE, ALERT_SILENCE_CHANGE, DEPLOYMENT, LICENSE`), `AuditRepository` — audit trail records and persistence
## http/ — Outbound HTTP primitives (cross-cutting)

View File

@@ -13,19 +13,40 @@ paths:
When deployed via the cameleer-saas platform, this server orchestrates customer app containers using Docker. Key components:
- **ConfigMerger** (`core/runtime/ConfigMerger.java`) — pure function: resolve(globalDefaults, envConfig, appConfig) -> ResolvedContainerConfig. Three-layer merge: global (application.yml) -> environment (defaultContainerConfig JSONB) -> app (containerConfig JSONB). Includes `runtimeType` (default `"auto"`) and `customArgs` (default `""`).
- **TraefikLabelBuilder** (`app/runtime/TraefikLabelBuilder.java`) — generates Traefik Docker labels for path-based (`/{envSlug}/{appSlug}/`) or subdomain-based (`{appSlug}-{envSlug}.{domain}`) routing. Supports strip-prefix and SSL offloading toggles. Also sets per-replica identity labels: `cameleer.replica` (index) and `cameleer.instance-id` (`{envSlug}-{appSlug}-{replicaIndex}`). Internal processing uses labels (not container name parsing) for extensibility.
- **TraefikLabelBuilder** (`app/runtime/TraefikLabelBuilder.java`) — generates Traefik Docker labels for path-based (`/{envSlug}/{appSlug}/`) or subdomain-based (`{appSlug}-{envSlug}.{domain}`) routing. Supports strip-prefix and SSL offloading toggles. Per-replica identity labels: `cameleer.replica` (index), `cameleer.generation` (8-char deployment UUID prefix — pin Prometheus/Grafana deploy boundaries with this), `cameleer.instance-id` (`{envSlug}-{appSlug}-{replicaIndex}-{generation}`). Traefik router/service keys deliberately omit the generation so load balancing spans old + new replicas during a blue/green overlap. When `ResolvedContainerConfig.externalRouting()` is `false` (UI: Resources → External Routing, default `true`), the builder emits ONLY the identity labels (`managed-by`, `cameleer.*`) and skips every `traefik.*` label — the container stays on `cameleer-traefik` and the per-env network (so sibling containers can still reach it via Docker DNS) but is invisible to Traefik. The `tls.certresolver` label is emitted only when `CAMELEER_SERVER_RUNTIME_CERTRESOLVER` is set to a non-blank resolver name (matching a resolver configured in the Traefik static config). When unset (dev installs backed by a static TLS store) only `tls=true` is emitted and Traefik serves the default cert from the TLS store.
- **PrometheusLabelBuilder** (`app/runtime/PrometheusLabelBuilder.java`) — generates Prometheus `docker_sd_configs` labels per resolved runtime type: Spring Boot `/actuator/prometheus:8081`, Quarkus/native `/q/metrics:9000`, plain Java `/metrics:9464`. Labels merged into container metadata alongside Traefik labels at deploy time.
- **DockerNetworkManager** (`app/runtime/DockerNetworkManager.java`) — manages two Docker network tiers:
- `cameleer-traefik` — shared network; Traefik, server, and all app containers attach here. Server joined via docker-compose with `cameleer-server` DNS alias.
- `cameleer-env-{slug}` — per-environment isolated network; containers in the same environment discover each other via Docker DNS. In SaaS mode, env networks are tenant-scoped: `cameleer-env-{tenantId}-{envSlug}` (overloaded `envNetworkName(tenantId, envSlug)` method) to prevent cross-tenant collisions when multiple tenants have identically-named environments.
- **DockerEventMonitor** (`app/runtime/DockerEventMonitor.java`) — persistent Docker event stream listener for containers with `managed-by=cameleer-server` label. Detects die/oom/start/stop events and updates deployment replica states. Periodic reconciliation (@Scheduled every 30s) inspects actual container state and corrects deployment status mismatches (fixes stale DEGRADED with all replicas healthy).
- **DeploymentProgress** (`ui/src/components/DeploymentProgress.tsx`) — UI step indicator showing 7 deploy stages with amber active/green completed styling.
- **ContainerLogForwarder** (`app/runtime/ContainerLogForwarder.java`) — streams Docker container stdout/stderr to ClickHouse `logs` table with `source='container'`. Uses `docker logs --follow` per container, batches lines every 2s or 50 lines. Parses Docker timestamp prefix, infers log level via regex. `DeploymentExecutor` starts capture after each replica launches with the replica's `instanceId` (`{envSlug}-{appSlug}-{replicaIndex}`); `DockerEventMonitor` stops capture on die/oom. 60-second max capture timeout with 30s cleanup scheduler. Thread pool of 10 daemon threads. Container logs use the same `instanceId` as the agent (set via `CAMELEER_AGENT_INSTANCEID` env var) for unified log correlation at the instance level.
- **ContainerLogForwarder** (`app/runtime/ContainerLogForwarder.java`) — streams Docker container stdout/stderr to ClickHouse `logs` table with `source='container'`. Uses `docker logs --follow` per container, batches lines every 2s or 50 lines. Parses Docker timestamp prefix, infers log level via regex. `DeploymentExecutor` starts capture after each replica launches with the replica's `instanceId` (`{envSlug}-{appSlug}-{replicaIndex}-{generation}`); `DockerEventMonitor` stops capture on die/oom. 60-second max capture timeout with 30s cleanup scheduler. Thread pool of 10 daemon threads. Container logs use the same `instanceId` as the agent (set via `CAMELEER_AGENT_INSTANCEID` env var) for unified log correlation at the instance level. Instance-id changes per deployment — cross-deploy queries aggregate on `application + environment` (and optionally `replica_index`).
- **StartupLogPanel** (`ui/src/components/StartupLogPanel.tsx`) — collapsible log panel rendered below `DeploymentProgress`. Queries `/api/v1/logs?source=container&application={appSlug}&environment={envSlug}`. Auto-polls every 3s while deployment is STARTING; shows green "live" badge during polling, red "stopped" badge on FAILED. Uses `useStartupLogs` hook and `LogViewer` (design system).
## Container Hardening (issue #152)
`DockerRuntimeOrchestrator.startContainer` applies an unconditional hardening contract to every tenant container — Java 17 has no SecurityManager so the JVM is not a security boundary, and isolation must live below it. Defaults are fail-closed and have no opt-out:
- `cap_drop` = every `Capability.values()` (effectively ALL — docker-java's enum has no `ALL` constant). Outbound TCP still works (no caps needed); raw sockets, ptrace, mounts, and bind <1024 are denied.
- `security_opt`: `no-new-privileges:true`, `apparmor=docker-default`. Default seccomp profile is applied implicitly when `seccomp=` is absent.
- `read_only` rootfs = true.
- `pids_limit` = 512 (`PIDS_LIMIT` constant).
- `tmpfs` mount: `/tmp` with `rw,nosuid,size=256m`. **No `noexec`** — Netty/tcnative, Snappy, LZ4, Zstd dlopen native libs from `/tmp` via `mmap(PROT_EXEC)` which `noexec` blocks. Issue #153 will add per-app `writeableVolumes` for stateful tenants (Kafka Streams etc.).
**Sandboxed runtime auto-detect**: at construction the orchestrator calls `dockerClient.infoCmd().exec().getRuntimes()` and uses `runsc` (gVisor) when present. Override with `cameleer.server.runtime.dockerruntime` (e.g. `kata` to force Kata Containers, or any other registered runtime). Empty/blank = auto. The override always wins over auto-detect. The `DockerRuntimeOrchestrator(DockerClient, String)` constructor is the canonical entry point; the single-arg constructor exists only as a convenience for tests that don't need an override.
## DeploymentExecutor Details
Primary network for app containers is set via `CAMELEER_SERVER_RUNTIME_DOCKERNETWORK` env var (in SaaS mode: `cameleer-tenant-{slug}`); apps also connect to `cameleer-traefik` (routing) and `cameleer-env-{tenantId}-{envSlug}` (per-environment discovery) as additional networks. Resolves `runtimeType: auto` to concrete type from `AppVersion.detectedRuntimeType` at PRE_FLIGHT (fails deployment if unresolvable). Builds Docker entrypoint per runtime type (all JVM types use `-javaagent:/app/agent.jar -jar`, plain Java uses `-cp` with main class, native runs binary directly). Sets per-replica `CAMELEER_AGENT_INSTANCEID` env var to `{envSlug}-{appSlug}-{replicaIndex}` so container logs and agent logs share the same instance identity. Sets `CAMELEER_AGENT_*` env vars from `ResolvedContainerConfig` (routeControlEnabled, replayEnabled, health port). These are startup-only agent properties — changing them requires redeployment.
Primary network for app containers is set via `CAMELEER_SERVER_RUNTIME_DOCKERNETWORK` env var (in SaaS mode: `cameleer-tenant-{slug}`); apps also connect to `cameleer-traefik` (routing) and `cameleer-env-{tenantId}-{envSlug}` (per-environment discovery) as additional networks. Resolves `runtimeType: auto` to concrete type from `AppVersion.detectedRuntimeType` at PRE_FLIGHT (fails deployment if unresolvable). Builds Docker entrypoint per runtime type (all JVM types use `-javaagent:/app/agent.jar -jar`, plain Java uses `-cp` with main class, native runs binary directly). Sets per-replica `CAMELEER_AGENT_INSTANCEID` env var to `{envSlug}-{appSlug}-{replicaIndex}-{generation}` so container logs and agent logs share the same instance identity. Sets `CAMELEER_AGENT_*` env vars from `ResolvedContainerConfig` (routeControlEnabled, replayEnabled, health port). These are startup-only agent properties — changing them requires redeployment.
**Container naming**`{tenantId}-{envSlug}-{appSlug}-{replicaIndex}-{generation}`, where `generation` is the first 8 characters of the deployment UUID. The generation suffix lets old + new replicas coexist during a blue/green swap (deterministic names without a generation used to 409). All lookups across the executor, `DockerEventMonitor`, and `ContainerLogForwarder` key on container **id**, not name — the name is operator-visibility only.
**Strategy dispatch**`DeploymentStrategy.fromWire(config.deploymentStrategy())` branches the executor. Unknown values fall back to BLUE_GREEN so misconfiguration never throws at runtime.
- **Blue/green** (default): start all N new replicas → wait for ALL healthy → stop the previous deployment. Resource peak ≈ 2× replicas for the health-check window. Partial health aborts with status FAILED; the previous deployment is preserved untouched (user's safety net).
- **Rolling**: replace replicas one at a time — start new[i] → wait healthy → stop old[i] → next. Resource peak = replicas + 1. Mid-rollout health failure stops in-flight new containers and aborts; already-replaced old replicas are NOT restored (not reversible) but un-replaced old[i+1..N] keep serving traffic. User redeploys to recover.
Traffic routing is implicit: Traefik labels (`cameleer.app`, `cameleer.environment`) are generation-agnostic, so new replicas attract load balancing as soon as they come up healthy — no explicit swap step.
## Deployment Status Model
@@ -34,17 +55,13 @@ Primary network for app containers is set via `CAMELEER_SERVER_RUNTIME_DOCKERNET
| `STOPPED` | Intentionally stopped or initial state |
| `STARTING` | Deploy in progress |
| `RUNNING` | All replicas healthy and serving |
| `DEGRADED` | Some replicas healthy, some dead |
| `DEGRADED` | Post-deploy: a replica died after the deploy was marked RUNNING. Set by `DockerEventMonitor` reconciliation, never by `DeploymentExecutor` directly. |
| `STOPPING` | Graceful shutdown in progress |
| `FAILED` | Terminal failure (pre-flight, health check, or crash) |
| `FAILED` | Terminal failure (pre-flight, health check, or crash). Partial-healthy deploys now mark FAILED — DEGRADED is reserved for post-deploy drift. |
**Replica support**: deployments can specify a replica count. `DEGRADED` is used when at least one but not all replicas are healthy.
**Deploy stages** (`DeployStage`): PRE_FLIGHT -> PULL_IMAGE -> CREATE_NETWORK -> START_REPLICAS -> HEALTH_CHECK -> SWAP_TRAFFIC -> COMPLETE (or FAILED at any stage). Rolling reuses the same stage labels inside the per-replica loop; the UI progress bar shows the most recent stage.
**Deploy stages** (`DeployStage`): PRE_FLIGHT -> PULL_IMAGE -> CREATE_NETWORK -> START_REPLICAS -> HEALTH_CHECK -> SWAP_TRAFFIC -> COMPLETE (or FAILED at any stage).
**Blue/green strategy**: when re-deploying, new replicas are started and health-checked before old ones are stopped, minimising downtime.
**Deployment uniqueness**: `DeploymentService.createDeployment()` deletes any STOPPED/FAILED deployments for the same app+environment before creating a new one, preventing duplicate rows.
**Deployment retention**: `DeploymentService.createDeployment()` deletes FAILED deployments for the same app+environment before creating a new one, preventing failed-attempt buildup. STOPPED deployments are preserved as restorable checkpoints — the UI Checkpoints disclosure lists every deployment with a non-null `deployed_config_snapshot` (RUNNING, DEGRADED, STOPPED) minus the current one.
## JAR Management

View File

@@ -8,7 +8,9 @@ paths:
# Prometheus Metrics
Server exposes `/api/v1/prometheus` (unauthenticated, Prometheus text format). Spring Boot Actuator provides JVM, GC, thread pool, and `http.server.requests` metrics automatically. Business metrics via `ServerMetrics` component:
Server exposes `/api/v1/prometheus` (unauthenticated, Prometheus text format). Spring Boot Actuator provides JVM, GC, thread pool, and `http.server.requests` metrics automatically. Business metrics via `ServerMetrics` component.
The same `MeterRegistry` is also snapshotted to ClickHouse every 60 s by `ServerMetricsSnapshotScheduler` (see "Server self-metrics persistence" at the bottom of this file) — so historical server-health data survives restarts without an external Prometheus.
## Gauges (auto-polled)
@@ -83,3 +85,23 @@ Mean processing time = `camel.route.policy.total_time / camel.route.policy.count
| `cameleer.sse.reconnects.count` | counter | `instanceId` |
| `cameleer.taps.evaluated.count` | counter | `instanceId` |
| `cameleer.metrics.exported.count` | counter | `instanceId` |
## Server self-metrics persistence
`ServerMetricsSnapshotScheduler` walks `MeterRegistry.getMeters()` every 60 s (configurable via `cameleer.server.self-metrics.interval-ms`) and writes one row per Micrometer `Measurement` to the ClickHouse `server_metrics` table. Full registry is captured — Spring Boot Actuator series (`jvm.*`, `process.*`, `http.server.requests`, `hikaricp.*`, `jdbc.*`, `tomcat.*`, `logback.events`, `system.*`) plus `cameleer.*` and `alerting_*`.
**Table** (`cameleer-server-app/src/main/resources/clickhouse/init.sql`):
```
server_metrics(tenant_id, collected_at, server_instance_id,
metric_name, metric_type, statistic, metric_value,
tags Map(String,String), server_received_at)
```
- `metric_type` — lowercase Micrometer `Meter.Type` (counter, gauge, timer, distribution_summary, long_task_timer, other)
- `statistic` — Micrometer `Statistic.getTagValueRepresentation()` (value, count, total, total_time, max, mean, active_tasks, duration). Timers emit 3 rows per tick (count + total_time + max); gauges/counters emit 1 (`statistic='value'` or `'count'`).
- No `environment` column — the server is env-agnostic.
- `tenant_id` threaded from `cameleer.server.tenant.id` (single-tenant per server).
- `server_instance_id` resolved once at boot by `ServerInstanceIdConfig` (property → HOSTNAME → localhost → UUID fallback). Rotates across restarts so counter resets are unambiguous.
- TTL: 90 days (vs 365 for `agent_metrics`). Write-only in v1 — no query endpoint or UI page. Inspect via ClickHouse admin: `/api/v1/admin/clickhouse/query` or direct SQL.
- Toggle off entirely with `cameleer.server.self-metrics.enabled=false` (uses `@ConditionalOnProperty`).

View File

@@ -10,13 +10,18 @@ The UI has 4 main tabs: **Exchanges**, **Dashboard**, **Runtime**, **Deployments
- **Exchanges** — route execution search and detail (`ui/src/pages/Exchanges/`)
- **Dashboard** — metrics and stats with L1/L2/L3 drill-down (`ui/src/pages/DashboardTab/`)
- **Runtime** — live agent status, logs, commands (`ui/src/pages/RuntimeTab/`). AgentHealth supports compact view (dense health-tinted cards) and expanded view (full GroupCard+DataTable per app). View mode persisted to localStorage.
- **Deployments** — app management, JAR upload, deployment lifecycle (`ui/src/pages/AppsTab/`)
- Config sub-tabs: **Monitoring | Resources | Variables | Traces & Taps | Route Recording**
- Create app: full page at `/apps/new` (not a modal)
- Deployment progress: `ui/src/components/DeploymentProgress.tsx` (7-stage step indicator)
- **Deployments** — unified app deployment page (`ui/src/pages/AppsTab/`)
- Routes: `/apps` (list, `AppListView` in `AppsTab.tsx`), `/apps/new` + `/apps/:slug` (both render `AppDeploymentPage`).
- Identity & Artifact section always visible; name editable pre-first-deploy, read-only after. JAR picker client-stages; new JAR + any form edits flip the primary button from `Save` to `Redeploy`. Environment fixed to the currently-selected env (no selector).
- Config sub-tabs: **Monitoring | Resources | Variables | Sensitive Keys | Deployment | ● Traces & Taps | ● Route Recording**. The four staged tabs feed dirty detection; the `●` live tabs apply in real-time (amber LiveBanner + default `?apply=live` on their writes) and never mark dirty.
- Primary action state machine: `Save``Uploading… N%` (during JAR upload; button shows percent with a tinted progress-fill overlay) → `Redeploy``Deploying…` during active deploy. Upload progress sourced from `useUploadJar` (XHR `upload.onprogress` → page-level `uploadPct` state). The button is disabled during `uploading` and `deploying`.
- Checkpoints render as a collapsible `CheckpointsTable` (default **collapsed**) **inside the Identity & Artifact `configGrid`** as an in-grid row (`Checkpoints | ▸ Expand (N)` / `▾ Collapse (N)`). `CheckpointsTable` returns a React.Fragment of grid-ready children so the label + trigger align with the other identity rows; when opened, a third grid child spans both columns via `grid-column: 1 / -1` so the 7-column table gets full width. Wired through `IdentitySection.checkpointsSlot``CheckpointDetailDrawer` stays in `IdentitySection.children` because it portals. Columns: Version · JAR (filename) · Deployed by · Deployed (relative `timeAgo` + user-locale sub-line via `new Date(iso).toLocaleString()`) · Strategy · Outcome · . Row click opens the drawer. Drawer tabs are ordered **Config | Logs** with `Config` as the default. Config panel has Snapshot / Diff vs current view modes. Replica filter in the Logs panel uses DS `Select`. Restore lives in the drawer footer (forces review). Visible row cap = `Environment.jarRetentionCount` (default 10 if 0/null); older rows accessible via "Show older (N)" expander. Currently-running deployment is excluded — represented separately by `StatusCard`. The empty-checkpoints case returns `null` (no row). The legacy `Checkpoints.tsx` row-list component is gone.
- Deployment tab: `StatusCard` + `DeploymentProgress` (during STARTING / FAILED) + flex-grow `StartupLogPanel` (no fixed maxHeight). Auto-activates when a deploy starts. The former `HistoryDisclosure` is retired — per-deployment config and logs live in the Checkpoints drawer. `StartupLogPanel` header mirrors the Runtime Application Log pattern: title + live/stopped badge + `N entries` + sort toggle (↑/↓, default **desc**) + refresh icon (`RefreshCw`). Sort drives the backend fetch via `useStartupLogs(…, sort)` so the 500-line limit returns the window closest to the user's interest; display order matches fetch order. Refresh scrolls to the latest edge (top for desc, bottom for asc). Sort + refresh buttons disable while a refetch is in flight. 3s polling while STARTING is unchanged.
- Unsaved-change router blocker uses DS `AlertDialog` (not `window.beforeunload`). Env switch intentionally discards edits without warning.
**Admin pages** (ADMIN-only, under `/admin/`):
- **Sensitive Keys** (`ui/src/pages/Admin/SensitiveKeysPage.tsx`) — global sensitive key masking config. Shows agent built-in defaults as outlined Badge reference, editable Tag pills for custom keys, amber-highlighted push-to-agents toggle. Keys add to (not replace) agent defaults. Per-app sensitive key additions managed via `ApplicationConfigController` API. Note: `AppConfigDetailPage.tsx` exists but is not routed in `router.tsx`.
- **Server Metrics** (`ui/src/pages/Admin/ServerMetricsAdminPage.tsx`) — dashboard over the `server_metrics` ClickHouse table. Visibility matches Database/ClickHouse pages: gated on `capabilities.infrastructureEndpoints` in `buildAdminTreeNodes`; backend is `@ConditionalOnProperty(infrastructureendpoints) + @PreAuthorize('hasRole(ADMIN)')`. Uses the generic `/api/v1/admin/server-metrics/{catalog,instances,query}` API via `ui/src/api/queries/admin/serverMetrics.ts` hooks (`useServerMetricsCatalog`, `useServerMetricsInstances`, `useServerMetricsSeries`), all three of which take a `ServerMetricsRange = { from: Date; to: Date }`. Time range is driven by the global TopBar picker via `useGlobalFilters()` — no page-local selector; bucket size auto-scales through `stepSecondsFor(windowSeconds)` (10 s up to 1 h buckets). Toolbar is just server-instance badges. Sections: Server health (agents/ingestion/auth), JVM (memory/CPU/GC/threads), HTTP & DB pools, Alerting (conditional on catalog), Deployments (conditional on catalog). Each panel is a `ThemedChart` with `Line`/`Area` children from the design system; multi-series responses are flattened into overlap rows by bucket timestamp. Alerting and Deployments rows are hidden when their metrics aren't in the catalog (zero-deploy / alerting-disabled installs).
## Key UI Files
@@ -35,6 +40,7 @@ The UI has 4 main tabs: **Exchanges**, **Dashboard**, **Runtime**, **Deployments
- `ui/src/api/queries/agents.ts``useAgents` for agent list, `useInfiniteAgentEvents` for cursor-paginated timeline stream
- `ui/src/hooks/useInfiniteStream.ts` — tanstack `useInfiniteQuery` wrapper with top-gated auto-refetch, flattened `items[]`, and `refresh()` invalidator
- `ui/src/components/InfiniteScrollArea.tsx` — scrollable container with IntersectionObserver top/bottom sentinels. Streaming log/event views use this + `useInfiniteStream`. Bounded views (LogTab, StartupLogPanel) keep `useLogs`/`useStartupLogs`
- `ui/src/components/SideDrawer.tsx` — project-local right-slide drawer (DS has Modal but no Drawer). Portal-rendered, ESC + transparent-backdrop click closes, sticky header/footer, sizes md/lg/xl. Currently consumed only by `CheckpointDetailDrawer` — promote to `@cameleer/design-system` once a second consumer appears.
## Alerts

View File

@@ -5,8 +5,20 @@ on:
branches: [main, 'feature/**', 'fix/**', 'feat/**']
tags-ignore:
- 'v*'
paths-ignore:
- '.planning/**'
- 'docs/**'
- '**/*.md'
- '.claude/**'
- 'AGENTS.md'
- 'CLAUDE.md'
pull_request:
branches: [main]
paths-ignore:
- '.planning/**'
- 'docs/**'
- '**/*.md'
- '.claude/**'
delete:
jobs:
@@ -45,11 +57,25 @@ jobs:
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
restore-keys: ${{ runner.os }}-maven-
- name: Cache npm registry
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('ui/package-lock.json') }}
restore-keys: ${{ runner.os }}-npm-
- name: Cache Vite build artifacts
uses: actions/cache@v4
with:
path: ui/node_modules/.vite
key: ${{ runner.os }}-vite-${{ hashFiles('ui/package-lock.json', 'ui/vite.config.ts') }}
restore-keys: ${{ runner.os }}-vite-
- name: Build UI
working-directory: ui
run: |
echo '//gitea.siegeln.net/api/packages/cameleer/npm/:_authToken=${REGISTRY_TOKEN}' >> .npmrc
npm ci
npm ci --prefer-offline --no-audit --fund=false
npm run build
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
@@ -58,6 +84,12 @@ jobs:
- name: Build and Test
run: mvn clean verify -DskipITs -U --batch-mode
- name: Deploy minter to Maven registry
if: github.event_name == 'push'
run: mvn deploy -DskipTests -DskipITs --batch-mode -pl .,cameleer-server-core,cameleer-license-minter
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
docker:
needs: build
runs-on: ubuntu-latest

View File

@@ -1,7 +1,7 @@
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **cameleer-server** (8893 symbols, 23049 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **cameleer-server** (9731 symbols, 24987 relationships, 300 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.

View File

@@ -22,8 +22,19 @@ Cameleer Server — observability server that receives, stores, and serves Camel
```bash
mvn clean compile # Compile all modules
mvn clean verify # Full build with tests
mvn clean verify -DskipITs # Fast: unit tests only (no Testcontainers)
```
### Faster local builds
- **Surefire reuses forks** (`cameleer-server-app/pom.xml`): unit tests run with `forkCount=1C` + `reuseForks=true` — one JVM per CPU core, reused across classes. Test classes that mutate static state must clean up after themselves.
- **Testcontainers reuse** — opt-in per developer. Add to `~/.testcontainers.properties`:
```
testcontainers.reuse.enable=true
```
Then `AbstractPostgresIT` containers persist across `mvn verify` runs (saves ~20s per run). Stop them manually when you need a clean DB: `docker rm -f $(docker ps -aq --filter label=org.testcontainers.reuse=true)`.
- **UI build** dropped redundant `tsc --noEmit` from `npm run build` (Vite/esbuild type-checks during bundling). Run `npm run typecheck` explicitly when you want a full type-check pass.
## Run
```bash
@@ -85,7 +96,7 @@ When adding, removing, or renaming classes, controllers, endpoints, UI component
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **cameleer-server** (8893 symbols, 23049 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **cameleer-server** (9731 symbols, 24987 relationships, 300 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.

View File

@@ -494,11 +494,13 @@ Key settings in `cameleer-server-app/src/main/resources/application.yml`. All cu
| `cameleer.server.runtime.enabled` | `true` | `CAMELEER_SERVER_RUNTIME_ENABLED` | Enable Docker orchestration |
| `cameleer.server.runtime.baseimage` | `cameleer-runtime-base:latest` | `CAMELEER_SERVER_RUNTIME_BASEIMAGE` | Base Docker image for app containers |
| `cameleer.server.runtime.dockernetwork` | `cameleer` | `CAMELEER_SERVER_RUNTIME_DOCKERNETWORK` | Primary Docker network |
| `cameleer.server.runtime.dockerruntime` | *(empty = auto)* | `CAMELEER_SERVER_RUNTIME_DOCKERRUNTIME` | Container runtime override. Empty auto-detects gVisor (`runsc`) when registered with the daemon and falls back to the daemon default. Set to e.g. `kata` to force a specific runtime, or `runc` to force the default even if `runsc` is installed. |
| `cameleer.server.runtime.jarstoragepath` | `/data/jars` | `CAMELEER_SERVER_RUNTIME_JARSTORAGEPATH` | JAR file storage directory |
| `cameleer.server.runtime.jardockervolume` | *(empty)* | `CAMELEER_SERVER_RUNTIME_JARDOCKERVOLUME` | Docker volume for JAR sharing |
| `cameleer.server.runtime.routingmode` | `path` | `CAMELEER_SERVER_RUNTIME_ROUTINGMODE` | `path` or `subdomain` Traefik routing |
| `cameleer.server.runtime.routingdomain` | `localhost` | `CAMELEER_SERVER_RUNTIME_ROUTINGDOMAIN` | Domain for Traefik routing labels |
| `cameleer.server.runtime.serverurl` | *(empty)* | `CAMELEER_SERVER_RUNTIME_SERVERURL` | Server URL injected into app containers |
| `cameleer.server.runtime.certresolver` | *(empty)* | `CAMELEER_SERVER_RUNTIME_CERTRESOLVER` | Traefik TLS cert resolver name (e.g. `letsencrypt`). Blank = omit the `tls.certresolver` label and let Traefik serve the default TLS-store cert |
| `cameleer.server.runtime.agenthealthport` | `9464` | `CAMELEER_SERVER_RUNTIME_AGENTHEALTHPORT` | Agent health check port |
| `cameleer.server.runtime.healthchecktimeout` | `60` | `CAMELEER_SERVER_RUNTIME_HEALTHCHECKTIMEOUT` | Health check timeout (seconds) |
| `cameleer.server.runtime.container.memorylimit` | `512m` | `CAMELEER_SERVER_RUNTIME_CONTAINER_MEMORYLIMIT` | Default memory limit for app containers |

View File

@@ -0,0 +1,287 @@
# cameleer-license-minter
Standalone vendor-side tool for producing signed Ed25519 license tokens consumed by `cameleer-server`. The minter is intentionally **not** a runtime or compile-scope dependency of the server — the server only ships with the matching public key and validates tokens via `LicenseValidator`. The private signing key never leaves the vendor's environment.
- Module GAV: `com.cameleer:cameleer-license-minter:1.0-SNAPSHOT`
- Maven coordinates of the runtime server (does **not** transitively pull this module): `com.cameleer:cameleer-server-app:1.0-SNAPSHOT`
- Build artifacts (after `mvn -pl cameleer-license-minter package`):
- `target/cameleer-license-minter-1.0-SNAPSHOT.jar` — plain library JAR (consumable as a Maven `test` dependency or via the `LicenseMinter` API in custom tooling)
- `target/cameleer-license-minter-1.0-SNAPSHOT-cli.jar` — fat CLI JAR with main class `com.cameleer.license.minter.cli.LicenseMinterCli`
## Table of contents
## Audience
## Build
## Public Java API
## CLI usage
## Token format
## LicenseInfo schema
## Limits dictionary
## Generating an Ed25519 key pair
## Worked example
## Security guidance
## Compatibility / runtime separation
---
## Audience
Vendors / SaaS operators issuing licenses to customers who run `cameleer-server`. End-customer operators looking for *how to install* a token should read `docs/license-enforcement.md` instead.
## Build
```bash
# From the repo root
mvn -pl cameleer-license-minter package
```
Two JARs land in `cameleer-license-minter/target/`:
| Artifact | Purpose |
|---|---|
| `cameleer-license-minter-1.0-SNAPSHOT.jar` | Plain library (the `repackage` execution for the main artifact is disabled; see `pom.xml:50-54`). Use this when embedding the minter inside your own tooling or a unit test that needs a fresh signed token. |
| `cameleer-license-minter-1.0-SNAPSHOT-cli.jar` | Fat CLI JAR. Repackaged by Spring Boot's `spring-boot-maven-plugin` with classifier `cli`; main class is `com.cameleer.license.minter.cli.LicenseMinterCli`. |
## Public Java API
`com.cameleer.license.minter.LicenseMinter` is the only entry point for the library. It is a final, stateless utility class:
```java
import com.cameleer.license.minter.LicenseMinter;
import com.cameleer.server.core.license.LicenseInfo;
LicenseInfo info = new LicenseInfo(
java.util.UUID.randomUUID(),
"acme-prod", // tenantId — must match server's CAMELEER_SERVER_TENANT_ID
"Acme Production (Tier B)", // human label, optional
java.util.Map.of(
"max_environments", 3,
"max_apps", 25,
"max_agents", 50,
"max_users", 20,
"max_total_replicas", 30
),
java.time.Instant.now(), // issuedAt
java.time.Instant.parse("2027-01-01T00:00:00Z"), // expiresAt
7 // gracePeriodDays
);
String token = LicenseMinter.mint(info, ed25519PrivateKey);
```
Source: `cameleer-license-minter/src/main/java/com/cameleer/license/minter/LicenseMinter.java:20`.
The method is thread-safe; the underlying Jackson `ObjectMapper` is configured once with `ORDER_MAP_ENTRIES_BY_KEYS` so canonical-JSON serialization is deterministic across runs and process boundaries.
`LicenseMinter.mint` will throw `IllegalStateException` if the JCE provider rejects the private key or the payload cannot be serialized.
## CLI usage
The CLI entry point is `com.cameleer.license.minter.cli.LicenseMinterCli`. Run it from the fat JAR produced by the build:
```bash
java -jar cameleer-license-minter/target/cameleer-license-minter-1.0-SNAPSHOT-cli.jar \
--private-key=/secure/keys/cameleer-license-priv.pem \
--tenant=acme-prod \
--label="Acme Production (Tier B)" \
--expires=2027-01-01 \
--grace-days=7 \
--max-environments=3 \
--max-apps=25 \
--max-agents=50 \
--max-users=20 \
--max-total-replicas=30 \
--output=/secure/out/acme-prod.lic \
--public-key=/secure/keys/cameleer-license-pub.b64 \
--verify
```
### Flag reference
Source of truth: `cameleer-license-minter/src/main/java/com/cameleer/license/minter/cli/LicenseMinterCli.java:26`.
| Flag | Required | Meaning |
|---|---|---|
| `--private-key=<path>` | yes | Path to a PKCS#8-encoded Ed25519 private key. Both PEM (`-----BEGIN PRIVATE KEY-----`) and raw base64 are accepted (`LicenseMinterCli.readEd25519PrivateKey`). |
| `--tenant=<tenantId>` | yes | The exact `tenantId` the server will compare against `CAMELEER_SERVER_TENANT_ID`. Mismatch causes the validator to throw at install / revalidation. |
| `--expires=<YYYY-MM-DD>` | yes | Expiration date interpreted as midnight UTC. The validator considers tokens expired once `now > exp + gracePeriodDays`. |
| `--label=<text>` | no | Human-readable label, surfaced via `GET /api/v1/admin/license` and `/api/v1/admin/license/usage`. |
| `--grace-days=<int>` | no | Number of days the license stays usable after `--expires`. Defaults to `0`. |
| `--max-<limitkey>=<int>` | no, repeatable | Each `--max-foo-bar` flag becomes the limit key `max_foo_bar`. See the limits dictionary below. Unknown keys are accepted by the minter (the server side ignores keys it does not understand and falls through to defaults). |
| `--output=<path>` | no | Write the token to a file. When omitted, the token is printed to stdout. On `--verify` failure the file is deleted. |
| `--public-key=<path>` | no, required for `--verify` | Path to the matching base64 X.509 SPKI public key file (one line, no PEM markers). |
| `--verify` | no | After minting, parse + signature-check the token using `--public-key` and `--tenant`. Exits non-zero if verification fails. |
Exit codes: `0` on success, `1` on minting / IO failure, `2` on argument validation failure, `3` on `--verify` failure.
## Token format
A token is the concatenation of two **standard** base64 segments joined by a literal `.`:
```
base64(canonicalJson) + "." + base64(ed25519Signature)
```
- The canonical JSON payload is produced by `LicenseMinter.canonicalPayload(...)` with keys sorted lexicographically and `limits` rendered as a sorted object. This makes the byte sequence deterministic given a fixed `LicenseInfo`.
- The signature is computed with `Signature.getInstance("Ed25519")` over the canonical payload bytes (not over the base64-encoded form).
- Encoding is `Base64.getEncoder()` (RFC 4648 §4 — *not* base64url). The validator decodes with the matching `Base64.getDecoder()`.
`LicenseValidator.validate(...)` (`cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseValidator.java:42`) splits on the first `.`, decodes both halves, verifies the signature, then deserializes the payload.
## LicenseInfo schema
Source: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseInfo.java`. Field-by-field:
| Field | Type | Required | Semantics |
|---|---|---|---|
| `licenseId` | `UUID` | yes | Stable identifier for this token. The server's audit trail records install/replace transitions by license id; renewals must use a fresh UUID so audit history is non-ambiguous. |
| `tenantId` | `String` | yes | Must equal the server's `CAMELEER_SERVER_TENANT_ID`. The validator throws `IllegalArgumentException` on mismatch. Blank values are rejected by the canonical record constructor. |
| `label` | `String` | no | Free-form human label. Surfaced on the admin/usage endpoints and the operator UI. Has no enforcement semantics. |
| `limits` | `Map<String,Integer>` | yes (may be empty) | License-specific overrides. Any key that appears here is unioned over `DefaultTierLimits.DEFAULTS` to form the effective caps in `ACTIVE` / `GRACE` states. Keys not present fall through to defaults. |
| `issuedAt` | `Instant` (epoch seconds in JSON `iat`) | yes | Stamped by the minter; not currently consulted by the validator beyond informational logging. |
| `expiresAt` | `Instant` (epoch seconds in JSON `exp`) | yes | The validator throws if `now > expiresAt + gracePeriodDays * 86400` at install or revalidation. |
| `gracePeriodDays` | `int` | yes (>= 0) | Window after `expiresAt` during which the gate transitions to `GRACE` (license still grants its caps) before flipping to `EXPIRED`. Negative values are rejected at construction. |
## Limits dictionary
Canonical key set: `cameleer-server-core/src/main/java/com/cameleer/server/core/license/DefaultTierLimits.java`. Any key not listed here is silently ignored by the server's `LicenseGate.getEffectiveLimits()`.
| CLI flag | Key | Default | What the server enforces |
|---|---|---|---|
| `--max-environments` | `max_environments` | 1 | `EnvironmentService.create(...)` consults `LicenseEnforcer.assertWithinCap("max_environments", currentCount, 1)`. |
| `--max-apps` | `max_apps` | 3 | `AppService.createApp(...)` checks total app count across all envs. |
| `--max-agents` | `max_agents` | 5 | `AgentRegistryService.register(...)` checks live agent count. |
| `--max-users` | `max_users` | 3 | User creation paths (`UserAdminController`, `UiAuthController` self-signup, `OidcAuthController` first-login). |
| `--max-outbound-connections` | `max_outbound_connections` | 1 | `OutboundConnectionServiceImpl.create(...)`. |
| `--max-alert-rules` | `max_alert_rules` | 2 | `AlertRuleController.create(...)`. |
| `--max-total-cpu-millis` | `max_total_cpu_millis` | 2000 | `DeploymentExecutor` PRE_FLIGHT compute cap (sum of `replicas * cpuLimit` over non-stopped deployments). |
| `--max-total-memory-mb` | `max_total_memory_mb` | 2048 | `DeploymentExecutor` PRE_FLIGHT compute cap (sum of `replicas * memoryLimitMb`). |
| `--max-total-replicas` | `max_total_replicas` | 5 | `DeploymentExecutor` PRE_FLIGHT compute cap (sum of `replicas`). |
| `--max-execution-retention-days` | `max_execution_retention_days` | 1 | ClickHouse TTL cap for `executions`, `processor_executions`. Effective TTL = `min(cap, env.executionRetentionDays)`. |
| `--max-log-retention-days` | `max_log_retention_days` | 1 | ClickHouse TTL cap for `logs`. |
| `--max-metric-retention-days` | `max_metric_retention_days` | 1 | ClickHouse TTL cap for `agent_metrics`, `agent_events`. |
| `--max-jar-retention-count` | `max_jar_retention_count` | 3 | `EnvironmentAdminController` PUT `/{envSlug}/jar-retention` rejects requests above this cap. Also bounds the daily `JarRetentionJob`. |
## Generating an Ed25519 key pair
The minter and validator both rely on the JCE `Ed25519` algorithm shipped with JDK 17+. No external crypto library is needed.
```java
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.util.Base64;
KeyPair kp = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
// 32-byte public key, X.509 SubjectPublicKeyInfo wrapped — this is what the server expects.
String publicKeyB64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
// PKCS#8 private key — the CLI's --private-key reader accepts this either as raw base64
// or PEM-wrapped (`-----BEGIN PRIVATE KEY-----`).
String privateKeyB64 = Base64.getEncoder().encodeToString(kp.getPrivate().getEncoded());
```
A one-liner using the JDK's `keytool` is **not** sufficient — `keytool` cannot produce raw Ed25519 PKCS#8 in a directly-usable shape for our reader. Generating via the API above (or `openssl genpkey -algorithm ed25519`) is the supported path.
For OpenSSL:
```bash
openssl genpkey -algorithm ed25519 -out cameleer-license-priv.pem
openssl pkey -in cameleer-license-priv.pem -pubout -outform DER \
| base64 -w0 > cameleer-license-pub.b64
```
The resulting `cameleer-license-pub.b64` is the value to put into `CAMELEER_SERVER_LICENSE_PUBLICKEY`.
## Worked example
End-to-end: generate a key pair, mint a license, install it on a running server, verify enforcement.
```bash
# 1. Vendor side — generate the keypair
openssl genpkey -algorithm ed25519 -out /secrets/cameleer-priv.pem
openssl pkey -in /secrets/cameleer-priv.pem -pubout -outform DER \
| base64 -w0 > /secrets/cameleer-pub.b64
# 2. Vendor side — distribute the public key (commit to deployment config / Vault / k8s Secret)
cat /secrets/cameleer-pub.b64
# MCowBQYDK2VwAyEAxxxxx...
# 3. Vendor side — mint a license for a customer tenant
mvn -pl cameleer-license-minter package -DskipTests
java -jar cameleer-license-minter/target/cameleer-license-minter-1.0-SNAPSHOT-cli.jar \
--private-key=/secrets/cameleer-priv.pem \
--public-key=/secrets/cameleer-pub.b64 \
--tenant=acme-prod \
--label="Acme Production" \
--expires=2027-01-01 \
--grace-days=14 \
--max-environments=3 \
--max-apps=25 \
--max-agents=50 \
--max-users=20 \
--max-total-replicas=30 \
--max-total-cpu-millis=15000 \
--max-total-memory-mb=16384 \
--max-execution-retention-days=30 \
--max-log-retention-days=14 \
--max-metric-retention-days=14 \
--max-jar-retention-count=10 \
--output=/tmp/acme.lic \
--verify
# 4. Customer side — server boots with public key + tenant id matching the mint
export CAMELEER_SERVER_TENANT_ID=acme-prod
export CAMELEER_SERVER_LICENSE_PUBLICKEY=$(cat /secrets/cameleer-pub.b64)
# 5. Customer side — install via the admin API after boot
curl -X POST https://server.example.com/api/v1/admin/license \
-H "Authorization: Bearer ${ADMIN_JWT}" \
-H "Content-Type: application/json" \
-d "{\"token\": \"$(cat /tmp/acme.lic)\"}"
# 6. Customer side — verify it was accepted
curl https://server.example.com/api/v1/admin/license \
-H "Authorization: Bearer ${ADMIN_JWT}"
# {"state":"ACTIVE","invalidReason":null,"envelope":{...},"lastValidatedAt":"..."}
curl https://server.example.com/api/v1/admin/license/usage \
-H "Authorization: Bearer ${ADMIN_JWT}"
# Shows current/cap/source per limit key
```
For boot-time installation (preferred for SaaS-managed deployments), set `CAMELEER_SERVER_LICENSE_TOKEN` instead of POSTing — see `docs/license-enforcement.md`.
## Security guidance
- **The Ed25519 private key is the trust root.** Anyone who holds it can mint licenses for any tenant. Treat it like a code-signing key.
- **Storage.** Production private keys belong in an HSM, KMS (e.g. AWS KMS / GCP KMS with non-exportable signing), or a sealed Vault transit backend. A sealed file on a laptop is acceptable for low-volume / pre-production minting only and should never be committed to git or shared via chat.
- **Rotation.** Rotation is destructive: every customer running with the *old* public key will reject all new tokens signed with the *new* private key. The pragmatic procedure is:
1. Generate the new keypair.
2. Distribute the new public key (`CAMELEER_SERVER_LICENSE_PUBLICKEY`) to every tenant's server config.
3. Once tenants confirm they are running with the new public key, re-mint and re-issue every active license under the new key.
4. Decommission the old private key.
Practical revocation flows through expiry — keep license terms short enough (12 months or less) that planned rotations stay aligned with renewal cadence.
- **Auditing.** The server records every install/replace/reject under `AuditCategory.LICENSE`. The minter itself does not write audit rows; if you need a vendor-side audit trail of mint operations, wrap `LicenseMinter.mint(...)` in your own ticketing pipeline.
- **Never commit private keys.** `.gitignore` does not block them by name — use a `secrets/` directory excluded by your repository's policy, or store them entirely outside the working tree.
## Compatibility / runtime separation
The minter is intentionally absent from `cameleer-server-app`'s production classpath. To verify after a build:
```bash
mvn -pl cameleer-server-app dependency:tree | grep license-minter
# expected: empty output (or, in development branches, a single line scoped 'test')
```
`cameleer-license-minter/pom.xml` depends on `cameleer-server-core` for `LicenseInfo` and the validator round-trip used by `--verify`. The server app intentionally does not depend on the minter — vendors mint outside the customer-deployed runtime, and a compromised customer cannot leverage server code to forge tokens.

View File

@@ -0,0 +1,69 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.cameleer</groupId>
<artifactId>cameleer-server-parent</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>
<artifactId>cameleer-license-minter</artifactId>
<name>Cameleer License Minter</name>
<description>Vendor-only Ed25519 license signing library + CLI</description>
<dependencies>
<dependency>
<groupId>com.cameleer</groupId>
<artifactId>cameleer-server-core</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<!-- Disable the default repackage so the main artifact stays as a plain library
JAR consumable as a Maven test-scope dependency by cameleer-server-app. -->
<execution>
<id>repackage</id>
<phase>none</phase>
</execution>
<execution>
<id>repackage-cli</id>
<goals>
<goal>repackage</goal>
</goals>
<configuration>
<classifier>cli</classifier>
<mainClass>com.cameleer.license.minter.cli.LicenseMinterCli</mainClass>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,52 @@
package com.cameleer.license.minter;
import com.cameleer.server.core.license.LicenseInfo;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.security.PrivateKey;
import java.security.Signature;
import java.util.Base64;
import java.util.TreeMap;
public final class LicenseMinter {
private static final ObjectMapper MAPPER = new ObjectMapper()
.configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true);
private LicenseMinter() {}
public static String mint(LicenseInfo info, PrivateKey ed25519PrivateKey) {
byte[] payload = canonicalPayload(info);
try {
Signature signer = Signature.getInstance("Ed25519");
signer.initSign(ed25519PrivateKey);
signer.update(payload);
byte[] sig = signer.sign();
return Base64.getEncoder().encodeToString(payload) + "." + Base64.getEncoder().encodeToString(sig);
} catch (Exception e) {
throw new IllegalStateException("Failed to sign license", e);
}
}
static byte[] canonicalPayload(LicenseInfo info) {
ObjectNode root = MAPPER.createObjectNode();
root.put("exp", info.expiresAt().getEpochSecond());
root.put("gracePeriodDays", info.gracePeriodDays());
root.put("iat", info.issuedAt().getEpochSecond());
if (info.label() != null) {
root.put("label", info.label());
}
root.put("licenseId", info.licenseId().toString());
ObjectNode limits = MAPPER.createObjectNode();
new TreeMap<>(info.limits()).forEach(limits::put);
root.set("limits", limits);
root.put("tenantId", info.tenantId());
try {
return MAPPER.writeValueAsBytes(root);
} catch (Exception e) {
throw new IllegalStateException("Failed to serialize license payload", e);
}
}
}

View File

@@ -0,0 +1,136 @@
package com.cameleer.license.minter.cli;
import com.cameleer.license.minter.LicenseMinter;
import com.cameleer.server.core.license.LicenseInfo;
import java.io.PrintStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.Base64;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.UUID;
public final class LicenseMinterCli {
private static final Set<String> KNOWN_FLAGS = Set.of(
"--private-key", "--public-key", "--tenant", "--label",
"--expires", "--grace-days", "--output", "--verify"
);
public static void main(String[] args) {
System.exit(run(args));
}
public static int run(String[] args) {
return run(args, System.out, System.err);
}
public static int run(String[] args, PrintStream out, PrintStream err) {
Map<String, String> flags = new LinkedHashMap<>();
Set<String> bool = new HashSet<>();
Map<String, Integer> limits = new TreeMap<>();
for (String arg : args) {
if (!arg.startsWith("--")) {
err.println("unexpected positional argument: " + arg);
return 2;
}
int eq = arg.indexOf('=');
String key = eq < 0 ? arg : arg.substring(0, eq);
String value = eq < 0 ? null : arg.substring(eq + 1);
if (key.startsWith("--max-")) {
String limitKey = "max_" + key.substring("--max-".length()).replace('-', '_');
if (value == null) {
err.println("missing value for " + key);
return 2;
}
limits.put(limitKey, Integer.parseInt(value));
continue;
}
if (!KNOWN_FLAGS.contains(key)) {
err.println("unknown flag: " + key);
return 2;
}
if (value == null) {
bool.add(key);
} else {
flags.put(key, value);
}
}
String privPath = flags.get("--private-key");
String tenant = flags.get("--tenant");
String expiresIso = flags.get("--expires");
if (privPath == null || tenant == null || expiresIso == null) {
err.println("required: --private-key --tenant --expires");
return 2;
}
try {
PrivateKey privateKey = readEd25519PrivateKey(Path.of(privPath));
int graceDays = Integer.parseInt(flags.getOrDefault("--grace-days", "0"));
Instant exp = LocalDate.parse(expiresIso).atStartOfDay(ZoneOffset.UTC).toInstant();
LicenseInfo info = new LicenseInfo(
UUID.randomUUID(),
tenant,
flags.get("--label"),
Collections.unmodifiableMap(limits),
Instant.now(),
exp,
graceDays
);
String token = LicenseMinter.mint(info, privateKey);
String outPath = flags.get("--output");
if (outPath != null) {
Files.writeString(Path.of(outPath), token);
out.println("wrote " + outPath);
} else {
out.println(token);
}
if (bool.contains("--verify")) {
String pubPath = flags.get("--public-key");
if (pubPath == null) {
err.println("--verify requires --public-key");
if (outPath != null) Files.deleteIfExists(Path.of(outPath));
return 2;
}
try {
String pubB64 = Files.readString(Path.of(pubPath)).trim();
new com.cameleer.server.core.license.LicenseValidator(pubB64, tenant).validate(token);
out.println("verified ok");
} catch (Exception ve) {
err.println("VERIFY FAILED: " + ve.getMessage());
if (outPath != null) Files.deleteIfExists(Path.of(outPath));
return 3;
}
}
return 0;
} catch (Exception e) {
err.println("ERROR: " + e.getMessage());
return 1;
}
}
private static PrivateKey readEd25519PrivateKey(Path path) throws Exception {
String s = Files.readString(path).trim();
if (s.startsWith("-----BEGIN")) {
s = s.replaceAll("-----BEGIN [A-Z ]+-----", "")
.replaceAll("-----END [A-Z ]+-----", "")
.replaceAll("\\s", "");
}
byte[] der = Base64.getDecoder().decode(s);
return KeyFactory.getInstance("Ed25519")
.generatePrivate(new PKCS8EncodedKeySpec(der));
}
}

View File

@@ -0,0 +1,53 @@
package com.cameleer.license.minter;
import com.cameleer.server.core.license.LicenseInfo;
import com.cameleer.server.core.license.LicenseValidator;
import org.junit.jupiter.api.Test;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.time.Instant;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
class LicenseMinterTest {
@Test
void roundTrip_validatorAcceptsMintedToken() throws Exception {
KeyPair kp = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
String publicB64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
LicenseInfo info = new LicenseInfo(
UUID.randomUUID(), "acme", "ACME prod",
Map.of("max_apps", 50, "max_agents", 100),
Instant.now(), Instant.now().plusSeconds(86400), 7);
String token = LicenseMinter.mint(info, kp.getPrivate());
LicenseInfo parsed = new LicenseValidator(publicB64, "acme").validate(token);
assertThat(parsed.licenseId()).isEqualTo(info.licenseId());
assertThat(parsed.tenantId()).isEqualTo("acme");
assertThat(parsed.limits().get("max_apps")).isEqualTo(50);
assertThat(parsed.gracePeriodDays()).isEqualTo(7);
}
@Test
void canonicalJson_isStableAcrossRuns() throws Exception {
KeyPair kp = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
UUID id = UUID.randomUUID();
Instant now = Instant.parse("2026-04-25T10:00:00Z");
Instant exp = Instant.parse("2027-04-25T10:00:00Z");
LinkedHashMap<String, Integer> limits = new LinkedHashMap<>();
limits.put("max_apps", 5);
limits.put("max_agents", 10);
LicenseInfo info = new LicenseInfo(id, "acme", "label", limits, now, exp, 0);
String t1 = LicenseMinter.mint(info, kp.getPrivate());
String t2 = LicenseMinter.mint(info, kp.getPrivate());
assertThat(t1).isEqualTo(t2);
}
}

View File

@@ -0,0 +1,112 @@
package com.cameleer.license.minter.cli;
import com.cameleer.server.core.license.LicenseValidator;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.util.Base64;
import static org.assertj.core.api.Assertions.assertThat;
class LicenseMinterCliTest {
@TempDir Path tmp;
@Test
void mints_validToken_validatorAccepts() throws Exception {
KeyPair kp = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
Path priv = tmp.resolve("priv.b64");
Path pub = tmp.resolve("pub.b64");
Files.writeString(priv, Base64.getEncoder().encodeToString(kp.getPrivate().getEncoded()));
Files.writeString(pub, Base64.getEncoder().encodeToString(kp.getPublic().getEncoded()));
Path out = tmp.resolve("license.tok");
int code = LicenseMinterCli.run(new String[]{
"--private-key=" + priv,
"--tenant=acme",
"--label=ACME",
"--expires=2099-12-31",
"--grace-days=30",
"--max-apps=50",
"--output=" + out
});
assertThat(code).isEqualTo(0);
String token = Files.readString(out).trim();
var info = new LicenseValidator(Files.readString(pub).trim(), "acme").validate(token);
assertThat(info.tenantId()).isEqualTo("acme");
assertThat(info.limits().get("max_apps")).isEqualTo(50);
assertThat(info.gracePeriodDays()).isEqualTo(30);
}
@Test
void unknownFlag_failsFast() {
int code = LicenseMinterCli.run(new String[]{"--frobnicate=yes"});
assertThat(code).isNotZero();
}
@Test
void verify_happyPath_succeeds() throws Exception {
KeyPair kp = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
Path priv = tmp.resolve("priv.b64");
Path pub = tmp.resolve("pub.b64");
Files.writeString(priv, Base64.getEncoder().encodeToString(kp.getPrivate().getEncoded()));
Files.writeString(pub, Base64.getEncoder().encodeToString(kp.getPublic().getEncoded()));
Path out = tmp.resolve("license.tok");
int code = LicenseMinterCli.run(new String[]{
"--private-key=" + priv,
"--public-key=" + pub,
"--tenant=acme",
"--expires=2099-12-31",
"--output=" + out,
"--verify"
});
assertThat(code).isEqualTo(0);
assertThat(out).exists();
}
@Test
void verify_wrongPublicKey_deletesOutputAndExitsNonZero() throws Exception {
KeyPair signing = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
KeyPair other = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
Path priv = tmp.resolve("priv.b64");
Path pub = tmp.resolve("pub.b64");
Files.writeString(priv, Base64.getEncoder().encodeToString(signing.getPrivate().getEncoded()));
Files.writeString(pub, Base64.getEncoder().encodeToString(other.getPublic().getEncoded()));
Path out = tmp.resolve("license.tok");
int code = LicenseMinterCli.run(new String[]{
"--private-key=" + priv,
"--public-key=" + pub,
"--tenant=acme",
"--expires=2099-12-31",
"--output=" + out,
"--verify"
});
assertThat(code).isNotZero();
assertThat(out).doesNotExist();
}
@Test
void verify_withoutPublicKey_fails() throws Exception {
KeyPair kp = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
Path priv = tmp.resolve("priv.b64");
Files.writeString(priv, Base64.getEncoder().encodeToString(kp.getPrivate().getEncoded()));
int code = LicenseMinterCli.run(new String[]{
"--private-key=" + priv,
"--tenant=acme",
"--expires=2099-12-31",
"--verify"
});
assertThat(code).isNotZero();
}
}

View File

@@ -19,6 +19,12 @@
<groupId>com.cameleer</groupId>
<artifactId>cameleer-server-core</artifactId>
</dependency>
<dependency>
<groupId>com.cameleer</groupId>
<artifactId>cameleer-license-minter</artifactId>
<version>${project.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
@@ -189,8 +195,8 @@
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<forkCount>1</forkCount>
<reuseForks>false</reuseForks>
<forkCount>1C</forkCount>
<reuseForks>true</reuseForks>
</configuration>
</plugin>
<plugin>

View File

@@ -12,6 +12,7 @@ import com.cameleer.server.app.alerting.eval.EvalContext;
import com.cameleer.server.app.alerting.eval.EvalResult;
import com.cameleer.server.app.alerting.eval.TickCache;
import com.cameleer.server.app.alerting.notify.MustacheRenderer;
import com.cameleer.server.app.license.LicenseEnforcer;
import com.cameleer.server.app.web.EnvPath;
import com.cameleer.server.core.admin.AuditCategory;
import com.cameleer.server.core.admin.AuditResult;
@@ -78,6 +79,7 @@ public class AlertRuleController {
private final Map<ConditionKind, ConditionEvaluator<?>> evaluators;
private final Clock clock;
private final String tenantId;
private final LicenseEnforcer licenseEnforcer;
@SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection")
public AlertRuleController(AlertRuleRepository ruleRepo,
@@ -86,7 +88,8 @@ public class AlertRuleController {
MustacheRenderer renderer,
List<ConditionEvaluator<?>> evaluatorList,
Clock alertingClock,
@Value("${cameleer.server.tenant.id:default}") String tenantId) {
@Value("${cameleer.server.tenant.id:default}") String tenantId,
LicenseEnforcer licenseEnforcer) {
this.ruleRepo = ruleRepo;
this.connectionService = connectionService;
this.auditService = auditService;
@@ -97,6 +100,7 @@ public class AlertRuleController {
}
this.clock = alertingClock;
this.tenantId = tenantId;
this.licenseEnforcer = licenseEnforcer;
}
// -------------------------------------------------------------------------
@@ -126,6 +130,8 @@ public class AlertRuleController {
@Valid @RequestBody AlertRuleRequest req,
HttpServletRequest httpRequest) {
licenseEnforcer.assertWithinCap("max_alert_rules", ruleRepo.count(), 1);
validateAttributeKeys(req.condition());
validateBusinessRules(req);
validateWebhooks(req.webhooks(), env.id());

View File

@@ -61,7 +61,8 @@ public class LogPatternEvaluator implements ConditionEvaluator<LogPatternConditi
to,
null, // cursor
1, // limit (count query; value irrelevant)
"desc" // sort
"desc", // sort
null // instanceIds
);
return logStore.countLogs(req);
});

View File

@@ -113,6 +113,12 @@ public class PostgresAlertRuleRepository implements AlertRuleRepository {
jdbc.update("DELETE FROM alert_rules WHERE id = ?", id);
}
@Override
public long count() {
Long n = jdbc.queryForObject("SELECT COUNT(*) FROM alert_rules", Long.class);
return n == null ? 0L : n;
}
@Override
public List<AlertRule> claimDueRules(String instanceId, int batchSize, int claimTtlSeconds) {
String sql = """

View File

@@ -17,11 +17,13 @@ import org.springframework.context.annotation.Configuration;
public class AgentRegistryBeanConfig {
@Bean
public AgentRegistryService agentRegistryService(AgentRegistryConfig config) {
public AgentRegistryService agentRegistryService(AgentRegistryConfig config,
com.cameleer.server.app.license.LicenseEnforcer enforcer) {
return new AgentRegistryService(
config.getStaleThresholdMs(),
config.getDeadThresholdMs(),
config.getCommandExpiryMs()
config.getCommandExpiryMs(),
current -> enforcer.assertWithinCap("max_agents", current, 1)
);
}

View File

@@ -1,22 +1,48 @@
package com.cameleer.server.app.config;
import com.cameleer.server.app.license.LicenseRepository;
import com.cameleer.server.app.license.LicenseService;
import com.cameleer.server.core.admin.AuditService;
import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.server.core.license.LicenseInfo;
import com.cameleer.server.core.license.LicenseValidator;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.util.Base64;
import java.util.Optional;
/**
* License bean topology (4 beans, in dependency order):
*
* <ol>
* <li>{@link LicenseGate} — always present, mutated by {@link LicenseService}.</li>
* <li>{@link LicenseValidator} — always present. When no public key is configured, returns an
* always-failing override so any loaded token routes through {@code install()} and is
* audited as INVALID rather than silently ignored.</li>
* <li>{@link LicenseService} — single mediation point for install / replace / revalidate;
* audits + persists + publishes {@code LicenseChangedEvent}.</li>
* <li>{@link LicenseBootLoader} — {@code @PostConstruct} drives {@code loadInitial} after the
* Spring context is ready. Resolution order: env var &gt; license file &gt; persisted DB row.</li>
* </ol>
*/
@Configuration
public class LicenseBeanConfig {
private static final Logger log = LoggerFactory.getLogger(LicenseBeanConfig.class);
@Value("${cameleer.server.tenant.id:default}")
private String tenantId;
@Value("${cameleer.server.license.token:}")
private String licenseToken;
@@ -28,41 +54,77 @@ public class LicenseBeanConfig {
@Bean
public LicenseGate licenseGate() {
LicenseGate gate = new LicenseGate();
String token = resolveLicenseToken();
if (token == null || token.isBlank()) {
log.info("No license configured — running in open mode (all features enabled)");
return gate;
}
if (licensePublicKey == null || licensePublicKey.isBlank()) {
log.warn("License token provided but no public key configured (CAMELEER_SERVER_LICENSE_PUBLICKEY). Running in open mode.");
return gate;
}
try {
LicenseValidator validator = new LicenseValidator(licensePublicKey);
LicenseInfo info = validator.validate(token);
gate.load(info);
} catch (Exception e) {
log.error("Failed to validate license: {}. Running in open mode.", e.getMessage());
}
return gate;
return new LicenseGate();
}
private String resolveLicenseToken() {
if (licenseToken != null && !licenseToken.isBlank()) {
return licenseToken;
}
if (licenseFile != null && !licenseFile.isBlank()) {
@Bean
public LicenseValidator licenseValidator() {
if (licensePublicKey == null || licensePublicKey.isBlank()) {
log.warn("CAMELEER_SERVER_LICENSE_PUBLICKEY not set — all licenses will be rejected as INVALID");
// Generate a throwaway, structurally-valid Ed25519 keypair just to satisfy the
// parent constructor's X.509 SubjectPublicKeyInfo decode + Ed25519 point validation.
// The overridden validate(...) always throws, so the dummy key is never used to
// verify anything — it only exists so the bean is constructable in misconfigured
// installs and any token that is loaded routes to INVALID via install()'s catch.
try {
return Files.readString(Path.of(licenseFile)).trim();
KeyPair throwaway = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
String dummyPub = Base64.getEncoder().encodeToString(throwaway.getPublic().getEncoded());
return new LicenseValidator(dummyPub, tenantId) {
@Override
public LicenseInfo validate(String token) {
throw new IllegalStateException("license public key not configured");
}
};
} catch (Exception e) {
log.warn("Failed to read license file {}: {}", licenseFile, e.getMessage());
throw new IllegalStateException("Failed to construct fallback license validator", e);
}
}
return null;
return new LicenseValidator(licensePublicKey, tenantId);
}
@Bean
public LicenseService licenseService(LicenseRepository repo,
LicenseGate gate,
LicenseValidator validator,
AuditService audit,
ApplicationEventPublisher events) {
return new LicenseService(tenantId, repo, gate, validator, audit, events);
}
@Bean
public LicenseBootLoader licenseBootLoader(LicenseService svc) {
return new LicenseBootLoader(svc, licenseToken, licenseFile);
}
/**
* {@code @PostConstruct} bridge that converts env-var/file values into the
* {@code Optional<String>} pair {@link LicenseService#loadInitial} expects, so
* env-var, file, and DB paths share the same audit + event-publish code path.
*/
public static class LicenseBootLoader {
private final LicenseService svc;
private final String envToken;
private final String filePath;
public LicenseBootLoader(LicenseService svc, String envToken, String filePath) {
this.svc = svc;
this.envToken = envToken;
this.filePath = filePath;
}
@PostConstruct
public void load() {
Optional<String> env = (envToken != null && !envToken.isBlank())
? Optional.of(envToken) : Optional.empty();
Optional<String> file = Optional.empty();
if (filePath != null && !filePath.isBlank()) {
try {
file = Optional.of(Files.readString(Path.of(filePath)).trim());
} catch (Exception e) {
log.warn("Failed to read license file {}: {}", filePath, e.getMessage());
}
}
svc.loadInitial(env, file);
}
}
}

View File

@@ -9,6 +9,7 @@ import com.cameleer.server.core.runtime.AppService;
import com.cameleer.server.core.runtime.AppVersionRepository;
import com.cameleer.server.core.runtime.DeploymentRepository;
import com.cameleer.server.core.runtime.DeploymentService;
import com.cameleer.server.core.runtime.DirtyStateCalculator;
import com.cameleer.server.core.runtime.EnvironmentRepository;
import com.cameleer.server.core.runtime.EnvironmentService;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -49,14 +50,18 @@ public class RuntimeBeanConfig {
}
@Bean
public EnvironmentService environmentService(EnvironmentRepository repo) {
return new EnvironmentService(repo);
public EnvironmentService environmentService(EnvironmentRepository repo,
com.cameleer.server.app.license.LicenseEnforcer enforcer) {
return new EnvironmentService(repo, current ->
enforcer.assertWithinCap("max_environments", current, 1));
}
@Bean
public AppService appService(AppRepository appRepo, AppVersionRepository versionRepo,
@Value("${cameleer.server.runtime.jarstoragepath:/data/jars}") String jarStoragePath) {
return new AppService(appRepo, versionRepo, jarStoragePath);
@Value("${cameleer.server.runtime.jarstoragepath:/data/jars}") String jarStoragePath,
com.cameleer.server.app.license.LicenseEnforcer enforcer) {
return new AppService(appRepo, versionRepo, jarStoragePath,
current -> enforcer.assertWithinCap("max_apps", current, 1));
}
@Bean
@@ -64,6 +69,11 @@ public class RuntimeBeanConfig {
return new DeploymentService(deployRepo, appService, envService);
}
@Bean
public DirtyStateCalculator dirtyStateCalculator(ObjectMapper objectMapper) {
return new DirtyStateCalculator(objectMapper);
}
@Bean(name = "deploymentTaskExecutor")
public Executor deploymentTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();

View File

@@ -9,6 +9,8 @@ import com.cameleer.server.app.storage.ClickHouseRouteCatalogStore;
import com.cameleer.server.core.storage.RouteCatalogStore;
import com.cameleer.server.app.storage.ClickHouseMetricsQueryStore;
import com.cameleer.server.app.storage.ClickHouseMetricsStore;
import com.cameleer.server.app.storage.ClickHouseServerMetricsQueryStore;
import com.cameleer.server.app.storage.ClickHouseServerMetricsStore;
import com.cameleer.server.app.storage.ClickHouseStatsStore;
import com.cameleer.server.core.admin.AuditRepository;
import com.cameleer.server.core.admin.AuditService;
@@ -67,6 +69,19 @@ public class StorageBeanConfig {
return new ClickHouseMetricsQueryStore(tenantProperties.getId(), clickHouseJdbc);
}
@Bean
public ServerMetricsStore clickHouseServerMetricsStore(
@Qualifier("clickHouseJdbcTemplate") JdbcTemplate clickHouseJdbc) {
return new ClickHouseServerMetricsStore(clickHouseJdbc);
}
@Bean
public ServerMetricsQueryStore clickHouseServerMetricsQueryStore(
TenantProperties tenantProperties,
@Qualifier("clickHouseJdbcTemplate") JdbcTemplate clickHouseJdbc) {
return new ClickHouseServerMetricsQueryStore(tenantProperties.getId(), clickHouseJdbc);
}
// ── Execution Store ──────────────────────────────────────────────────
@Bean
@@ -188,4 +203,12 @@ public class StorageBeanConfig {
ClickHouseUsageTracker usageTracker) {
return new com.cameleer.server.app.analytics.UsageFlushScheduler(usageTracker);
}
// ── License Repository ───────────────────────────────────────────
@Bean
public com.cameleer.server.app.license.LicenseRepository licenseRepository(
JdbcTemplate jdbcTemplate) {
return new com.cameleer.server.app.license.PostgresLicenseRepository(jdbcTemplate);
}
}

View File

@@ -1,14 +1,24 @@
package com.cameleer.server.app.controller;
import com.cameleer.common.model.ApplicationConfig;
import com.cameleer.server.app.dto.DirtyStateResponse;
import com.cameleer.server.app.storage.PostgresApplicationConfigRepository;
import com.cameleer.server.app.storage.PostgresDeploymentRepository;
import com.cameleer.server.app.web.EnvPath;
import com.cameleer.server.core.runtime.App;
import com.cameleer.server.core.runtime.AppService;
import com.cameleer.server.core.runtime.AppVersion;
import com.cameleer.server.core.runtime.AppVersionRepository;
import com.cameleer.server.core.runtime.Deployment;
import com.cameleer.server.core.runtime.DeploymentConfigSnapshot;
import com.cameleer.server.core.runtime.DirtyStateCalculator;
import com.cameleer.server.core.runtime.DirtyStateResult;
import com.cameleer.server.core.runtime.Environment;
import com.cameleer.server.core.runtime.RuntimeType;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -22,8 +32,10 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@@ -40,9 +52,21 @@ import java.util.UUID;
public class AppController {
private final AppService appService;
private final AppVersionRepository appVersionRepository;
private final PostgresApplicationConfigRepository configRepository;
private final PostgresDeploymentRepository deploymentRepository;
private final DirtyStateCalculator dirtyCalc;
public AppController(AppService appService) {
public AppController(AppService appService,
AppVersionRepository appVersionRepository,
PostgresApplicationConfigRepository configRepository,
PostgresDeploymentRepository deploymentRepository,
DirtyStateCalculator dirtyCalc) {
this.appService = appService;
this.appVersionRepository = appVersionRepository;
this.configRepository = configRepository;
this.deploymentRepository = deploymentRepository;
this.dirtyCalc = dirtyCalc;
}
@GetMapping
@@ -120,6 +144,47 @@ public class AppController {
}
}
@GetMapping("/{appSlug}/dirty-state")
@Operation(summary = "Check whether the app's current config differs from the last successful deploy",
description = "Returns dirty=true when the desired state (current JAR + agent config + container config) "
+ "would produce a changed deployment. When no successful deploy exists yet, dirty=true.")
@ApiResponse(responseCode = "200", description = "Dirty-state computed")
@ApiResponse(responseCode = "404", description = "App not found in this environment")
public ResponseEntity<DirtyStateResponse> getDirtyState(@EnvPath Environment env,
@PathVariable String appSlug) {
App app;
try {
app = appService.getByEnvironmentAndSlug(env.id(), appSlug);
} catch (IllegalArgumentException e) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "App not found");
}
// Latest JAR version (newest first — findByAppId orders by version DESC)
List<AppVersion> versions = appVersionRepository.findByAppId(app.id());
UUID latestVersionId = versions.isEmpty() ? null
: versions.stream().max(Comparator.comparingInt(AppVersion::version))
.map(AppVersion::id).orElse(null);
// Desired agent config
ApplicationConfig agentConfig = configRepository
.findByApplicationAndEnvironment(appSlug, env.slug())
.orElse(null);
// Container config
Map<String, Object> containerConfig = app.containerConfig();
// Last successful deployment snapshot
Deployment lastSuccessful = deploymentRepository
.findLatestSuccessfulByAppAndEnv(app.id(), env.id())
.orElse(null);
DeploymentConfigSnapshot snapshot = lastSuccessful != null ? lastSuccessful.deployedConfigSnapshot() : null;
DirtyStateResult result = dirtyCalc.compute(latestVersionId, agentConfig, containerConfig, snapshot);
String lastId = lastSuccessful != null ? lastSuccessful.id().toString() : null;
return ResponseEntity.ok(new DirtyStateResponse(result.dirty(), lastId, result.differences()));
}
private static final java.util.regex.Pattern CUSTOM_ARGS_PATTERN =
java.util.regex.Pattern.compile("^[-a-zA-Z0-9_.=:/\\s+\"']*$");

View File

@@ -24,6 +24,7 @@ import com.cameleer.server.core.storage.DiagramStore;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
@@ -33,6 +34,7 @@ import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.ArrayList;
import java.util.List;
@@ -108,13 +110,23 @@ public class ApplicationConfigController {
@PutMapping("/apps/{appSlug}/config")
@Operation(summary = "Update application config for this environment",
description = "Saves config and pushes CONFIG_UPDATE to LIVE agents of this application in the given environment")
@ApiResponse(responseCode = "200", description = "Config saved and pushed")
description = "Saves config. When apply=live (default), also pushes CONFIG_UPDATE to LIVE agents. "
+ "When apply=staged, persists without a live push — the next successful deploy applies it.")
@ApiResponse(responseCode = "200", description = "Config saved (and pushed if apply=live)")
@ApiResponse(responseCode = "400", description = "Unknown apply value (must be 'staged' or 'live')")
public ResponseEntity<ConfigUpdateResponse> updateConfig(@EnvPath Environment env,
@PathVariable String appSlug,
@Parameter(name = "apply",
description = "When to apply: 'live' (default) saves and pushes CONFIG_UPDATE to live agents immediately; 'staged' saves without pushing — the next successful deploy applies it.")
@RequestParam(name = "apply", defaultValue = "live") String apply,
@RequestBody ApplicationConfig config,
Authentication auth,
HttpServletRequest httpRequest) {
if (!"staged".equalsIgnoreCase(apply) && !"live".equalsIgnoreCase(apply)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Unknown apply value '" + apply + "' — must be 'staged' or 'live'");
}
String updatedBy = auth != null ? auth.getName() : "system";
config.setApplication(appSlug);
@@ -126,14 +138,24 @@ public class ApplicationConfigController {
List<String> perAppKeys = extractSensitiveKeys(saved);
List<String> mergedKeys = SensitiveKeysMerger.merge(globalKeys, perAppKeys);
CommandGroupResponse pushResult = pushConfigToAgentsWithMergedKeys(appSlug, env.slug(), saved, mergedKeys);
log.info("Config v{} saved for '{}', pushed to {} agent(s), {} responded",
saved.getVersion(), appSlug, pushResult.total(), pushResult.responded());
CommandGroupResponse pushResult;
if ("staged".equalsIgnoreCase(apply)) {
pushResult = new CommandGroupResponse(true, 0, 0, List.of(), List.of());
log.info("Config v{} staged for '{}' (no live push)", saved.getVersion(), appSlug);
} else {
pushResult = pushConfigToAgentsWithMergedKeys(appSlug, env.slug(), saved, mergedKeys);
log.info("Config v{} saved for '{}', pushed to {} agent(s), {} responded",
saved.getVersion(), appSlug, pushResult.total(), pushResult.responded());
}
auditService.log("update_app_config", AuditCategory.CONFIG, appSlug,
auditService.log(
"staged".equalsIgnoreCase(apply) ? "stage_app_config" : "update_app_config",
AuditCategory.CONFIG, appSlug,
Map.of("environment", env.slug(), "version", saved.getVersion(),
"apply", apply.toLowerCase(),
"agentsPushed", pushResult.total(),
"responded", pushResult.responded(), "timedOut", pushResult.timedOut().size()),
"responded", pushResult.responded(),
"timedOut", pushResult.timedOut().size()),
AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok(new ConfigUpdateResponse(saved, pushResult));

View File

@@ -196,7 +196,16 @@ public class CatalogController {
}
Set<String> routeIds = routesByApp.getOrDefault(slug, Set.of());
List<String> agentIds = agents.stream().map(AgentInfo::instanceId).toList();
// Resolve the env slug for this row early so fromUri can survive
// cross-env queries (env==null) against managed apps.
String rowEnvSlug = envSlug;
if (app != null && rowEnvSlug.isEmpty()) {
try {
rowEnvSlug = envService.getById(app.environmentId()).slug();
} catch (Exception ignored) {}
}
final String resolvedEnvSlug = rowEnvSlug;
// Routes
List<RouteSummary> routeSummaries = routeIds.stream()
@@ -204,7 +213,7 @@ public class CatalogController {
String key = slug + "/" + routeId;
long count = routeExchangeCounts.getOrDefault(key, 0L);
Instant lastSeen = routeLastSeen.get(key);
String fromUri = resolveFromEndpointUri(routeId, agentIds);
String fromUri = resolveFromEndpointUri(slug, routeId, resolvedEnvSlug);
String state = routeStateRegistry.getState(slug, routeId).name().toLowerCase();
String routeState = "started".equals(state) ? null : state;
return new RouteSummary(routeId, count, lastSeen, fromUri, routeState);
@@ -258,15 +267,9 @@ public class CatalogController {
String healthTooltip = buildHealthTooltip(app != null, deployStatus, agentHealth, agents.size());
String displayName = app != null ? app.displayName() : slug;
String appEnvSlug = envSlug;
if (app != null && appEnvSlug.isEmpty()) {
try {
appEnvSlug = envService.getById(app.environmentId()).slug();
} catch (Exception ignored) {}
}
catalog.add(new CatalogApp(
slug, displayName, app != null, appEnvSlug,
slug, displayName, app != null, resolvedEnvSlug,
health, healthTooltip, agents.size(), routeSummaries, agentSummaries,
totalExchanges, deploymentSummary
));
@@ -275,8 +278,11 @@ public class CatalogController {
return ResponseEntity.ok(catalog);
}
private String resolveFromEndpointUri(String routeId, List<String> agentIds) {
return diagramStore.findContentHashForRouteByAgents(routeId, agentIds)
private String resolveFromEndpointUri(String applicationId, String routeId, String environment) {
if (environment == null || environment.isBlank()) {
return null;
}
return diagramStore.findLatestContentHashForAppRoute(applicationId, routeId, environment)
.flatMap(diagramStore::findByContentHash)
.map(RouteGraph::getRoot)
.map(root -> root.getEndpointUri())

View File

@@ -2,8 +2,13 @@ package com.cameleer.server.app.controller;
import com.cameleer.server.app.runtime.DeploymentExecutor;
import com.cameleer.server.app.web.EnvPath;
import com.cameleer.server.core.admin.AuditCategory;
import com.cameleer.server.core.admin.AuditResult;
import com.cameleer.server.core.admin.AuditService;
import com.cameleer.server.core.runtime.App;
import com.cameleer.server.core.runtime.AppService;
import com.cameleer.server.core.runtime.AppVersion;
import com.cameleer.server.core.runtime.AppVersionRepository;
import com.cameleer.server.core.runtime.Deployment;
import com.cameleer.server.core.runtime.DeploymentService;
import com.cameleer.server.core.runtime.Environment;
@@ -12,14 +17,18 @@ import com.cameleer.server.core.runtime.RuntimeOrchestrator;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Map;
@@ -42,17 +51,23 @@ public class DeploymentController {
private final RuntimeOrchestrator orchestrator;
private final AppService appService;
private final EnvironmentService environmentService;
private final AuditService auditService;
private final AppVersionRepository appVersionRepository;
public DeploymentController(DeploymentService deploymentService,
DeploymentExecutor deploymentExecutor,
RuntimeOrchestrator orchestrator,
AppService appService,
EnvironmentService environmentService) {
EnvironmentService environmentService,
AuditService auditService,
AppVersionRepository appVersionRepository) {
this.deploymentService = deploymentService;
this.deploymentExecutor = deploymentExecutor;
this.orchestrator = orchestrator;
this.appService = appService;
this.environmentService = environmentService;
this.auditService = auditService;
this.appVersionRepository = appVersionRepository;
}
@GetMapping
@@ -86,13 +101,25 @@ public class DeploymentController {
@ApiResponse(responseCode = "202", description = "Deployment accepted and starting")
public ResponseEntity<Deployment> deploy(@EnvPath Environment env,
@PathVariable String appSlug,
@RequestBody DeployRequest request) {
@RequestBody DeployRequest request,
HttpServletRequest httpRequest) {
try {
App app = appService.getByEnvironmentAndSlug(env.id(), appSlug);
Deployment deployment = deploymentService.createDeployment(app.id(), request.appVersionId(), env.id());
AppVersion appVersion = appVersionRepository.findById(request.appVersionId())
.orElseThrow(() -> new IllegalArgumentException("AppVersion not found: " + request.appVersionId()));
Deployment deployment = deploymentService.createDeployment(app.id(), request.appVersionId(), env.id(), currentUserId());
deploymentExecutor.executeAsync(deployment);
auditService.log("deploy_app", AuditCategory.DEPLOYMENT, deployment.id().toString(),
Map.of("appSlug", appSlug, "envSlug", env.slug(),
"appVersionId", request.appVersionId().toString(),
"jarFilename", appVersion.jarFilename() != null ? appVersion.jarFilename() : "",
"version", appVersion.version()),
AuditResult.SUCCESS, httpRequest);
return ResponseEntity.accepted().body(deployment);
} catch (IllegalArgumentException e) {
auditService.log("deploy_app", AuditCategory.DEPLOYMENT, null,
Map.of("appSlug", appSlug, "envSlug", env.slug(), "error", e.getMessage()),
AuditResult.FAILURE, httpRequest);
return ResponseEntity.notFound().build();
}
}
@@ -103,12 +130,19 @@ public class DeploymentController {
@ApiResponse(responseCode = "404", description = "Deployment not found")
public ResponseEntity<Deployment> stop(@EnvPath Environment env,
@PathVariable String appSlug,
@PathVariable UUID deploymentId) {
@PathVariable UUID deploymentId,
HttpServletRequest httpRequest) {
try {
Deployment deployment = deploymentService.getById(deploymentId);
deploymentExecutor.stopDeployment(deployment);
auditService.log("stop_deployment", AuditCategory.DEPLOYMENT, deploymentId.toString(),
Map.of("appSlug", appSlug, "envSlug", env.slug()),
AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok(deploymentService.getById(deploymentId));
} catch (IllegalArgumentException e) {
auditService.log("stop_deployment", AuditCategory.DEPLOYMENT, deploymentId.toString(),
Map.of("appSlug", appSlug, "envSlug", env.slug(), "error", e.getMessage()),
AuditResult.FAILURE, httpRequest);
return ResponseEntity.notFound().build();
}
}
@@ -122,18 +156,26 @@ public class DeploymentController {
public ResponseEntity<?> promote(@EnvPath Environment env,
@PathVariable String appSlug,
@PathVariable UUID deploymentId,
@RequestBody PromoteRequest request) {
@RequestBody PromoteRequest request,
HttpServletRequest httpRequest) {
try {
App sourceApp = appService.getByEnvironmentAndSlug(env.id(), appSlug);
Deployment source = deploymentService.getById(deploymentId);
Environment targetEnv = environmentService.getBySlug(request.targetEnvironment());
// Target must also have the app with the same slug
App targetApp = appService.getByEnvironmentAndSlug(targetEnv.id(), appSlug);
Deployment promoted = deploymentService.promote(targetApp.id(), source.appVersionId(), targetEnv.id());
Deployment promoted = deploymentService.promote(targetApp.id(), source.appVersionId(), targetEnv.id(), currentUserId());
deploymentExecutor.executeAsync(promoted);
auditService.log("promote_deployment", AuditCategory.DEPLOYMENT, promoted.id().toString(),
Map.of("sourceEnv", env.slug(), "targetEnv", request.targetEnvironment(),
"appSlug", appSlug, "appVersionId", source.appVersionId().toString()),
AuditResult.SUCCESS, httpRequest);
return ResponseEntity.accepted().body(promoted);
} catch (IllegalArgumentException e) {
return ResponseEntity.status(org.springframework.http.HttpStatus.NOT_FOUND)
auditService.log("promote_deployment", AuditCategory.DEPLOYMENT, deploymentId.toString(),
Map.of("sourceEnv", env.slug(), "targetEnv", request.targetEnvironment(),
"appSlug", appSlug, "error", e.getMessage()),
AuditResult.FAILURE, httpRequest);
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", e.getMessage()));
}
}
@@ -157,6 +199,15 @@ public class DeploymentController {
}
}
private String currentUserId() {
var auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || auth.getName() == null) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "No authentication");
}
String name = auth.getName();
return name.startsWith("user:") ? name.substring(5) : name;
}
public record DeployRequest(UUID appVersionId) {}
public record PromoteRequest(String targetEnvironment) {}
}

View File

@@ -2,8 +2,6 @@ package com.cameleer.server.app.controller;
import com.cameleer.common.graph.RouteGraph;
import com.cameleer.server.app.web.EnvPath;
import com.cameleer.server.core.agent.AgentInfo;
import com.cameleer.server.core.agent.AgentRegistryService;
import com.cameleer.server.core.diagram.DiagramLayout;
import com.cameleer.server.core.diagram.DiagramRenderer;
import com.cameleer.server.core.runtime.Environment;
@@ -21,7 +19,6 @@ import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Optional;
/**
@@ -42,14 +39,11 @@ public class DiagramRenderController {
private final DiagramStore diagramStore;
private final DiagramRenderer diagramRenderer;
private final AgentRegistryService registryService;
public DiagramRenderController(DiagramStore diagramStore,
DiagramRenderer diagramRenderer,
AgentRegistryService registryService) {
DiagramRenderer diagramRenderer) {
this.diagramStore = diagramStore;
this.diagramRenderer = diagramRenderer;
this.registryService = registryService;
}
@GetMapping("/api/v1/diagrams/{contentHash}/render")
@@ -90,8 +84,8 @@ public class DiagramRenderController {
@GetMapping("/api/v1/environments/{envSlug}/apps/{appSlug}/routes/{routeId}/diagram")
@Operation(summary = "Find the latest diagram for this app's route in this environment",
description = "Resolves agents in this env for this app, then looks up the latest diagram for the route "
+ "they reported. Env scope prevents a dev route from returning a prod diagram.")
description = "Returns the most recently stored diagram for (app, env, route). Independent of the "
+ "agent registry, so routes removed from the current app version still resolve.")
@ApiResponse(responseCode = "200", description = "Diagram layout returned")
@ApiResponse(responseCode = "404", description = "No diagram found")
public ResponseEntity<DiagramLayout> findByAppAndRoute(
@@ -99,15 +93,7 @@ public class DiagramRenderController {
@PathVariable String appSlug,
@PathVariable String routeId,
@RequestParam(defaultValue = "LR") String direction) {
List<String> agentIds = registryService.findByApplicationAndEnvironment(appSlug, env.slug()).stream()
.map(AgentInfo::instanceId)
.toList();
if (agentIds.isEmpty()) {
return ResponseEntity.notFound().build();
}
Optional<String> contentHash = diagramStore.findContentHashForRouteByAgents(routeId, agentIds);
Optional<String> contentHash = diagramStore.findLatestContentHashForAppRoute(appSlug, routeId, env.slug());
if (contentHash.isEmpty()) {
return ResponseEntity.notFound().build();
}

View File

@@ -1,5 +1,6 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.server.core.runtime.Environment;
import com.cameleer.server.core.runtime.EnvironmentColor;
import com.cameleer.server.core.runtime.EnvironmentService;
@@ -7,9 +8,11 @@ import com.cameleer.server.core.runtime.RuntimeType;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Map;
@@ -21,9 +24,11 @@ import java.util.Map;
public class EnvironmentAdminController {
private final EnvironmentService environmentService;
private final LicenseGate licenseGate;
public EnvironmentAdminController(EnvironmentService environmentService) {
public EnvironmentAdminController(EnvironmentService environmentService, LicenseGate licenseGate) {
this.environmentService = environmentService;
this.licenseGate = licenseGate;
}
@GetMapping
@@ -141,11 +146,24 @@ public class EnvironmentAdminController {
@Operation(summary = "Update JAR retention policy for an environment")
@ApiResponse(responseCode = "200", description = "Retention policy updated")
@ApiResponse(responseCode = "404", description = "Environment not found")
@ApiResponse(responseCode = "422", description = "jarRetentionCount exceeds license cap")
public ResponseEntity<?> updateJarRetention(@PathVariable String envSlug,
@RequestBody JarRetentionRequest request) {
try {
Environment current = environmentService.getBySlug(envSlug);
environmentService.updateJarRetentionCount(current.id(), request.jarRetentionCount());
// License cap check: only fires when a non-null value is supplied (null = unlimited).
// 422 (not 403) because this is a value-out-of-range, not a creation-quota rejection;
// therefore we do NOT route through LicenseEnforcer / LicenseExceptionAdvice.
Integer requested = request.jarRetentionCount();
if (requested != null) {
int cap = licenseGate.getEffectiveLimits().get("max_jar_retention_count");
if (requested > cap) {
throw new ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY,
"jarRetentionCount " + requested + " exceeds license cap "
+ cap + " (max_jar_retention_count)");
}
}
environmentService.updateJarRetentionCount(current.id(), requested);
return ResponseEntity.ok(environmentService.getBySlug(envSlug));
} catch (IllegalArgumentException e) {
if (e.getMessage().contains("not found")) {

View File

@@ -1,51 +1,71 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.app.license.LicenseRepository;
import com.cameleer.server.app.license.LicenseService;
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;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* License management for ADMIN users. All mutation goes through {@link LicenseService} so that
* install / replace flows are uniformly audited, persisted, and published to listeners (retention
* policy, license metrics, etc.).
*
* <p>GET returns {@code {state, invalidReason, envelope, lastValidatedAt?}}. The raw JWT-style
* token is deliberately omitted from the response — only the parsed {@link LicenseInfo} is
* exposed.</p>
*/
@RestController
@RequestMapping("/api/v1/admin/license")
@PreAuthorize("hasRole('ADMIN')")
@Tag(name = "License Admin", description = "License management")
public class LicenseAdminController {
private final LicenseGate licenseGate;
private final String licensePublicKey;
private final LicenseService licenseService;
private final LicenseGate gate;
private final LicenseRepository repo;
public LicenseAdminController(LicenseGate licenseGate,
@Value("${cameleer.server.license.publickey:}") String licensePublicKey) {
this.licenseGate = licenseGate;
this.licensePublicKey = licensePublicKey;
public LicenseAdminController(LicenseService svc, LicenseGate gate, LicenseRepository repo) {
this.licenseService = svc;
this.gate = gate;
this.repo = repo;
}
@GetMapping
@Operation(summary = "Get current license info")
public ResponseEntity<LicenseInfo> getCurrent() {
return ResponseEntity.ok(licenseGate.getCurrent());
@Operation(summary = "Get current license state, invalid reason, and parsed envelope")
public ResponseEntity<Map<String, Object>> getCurrent() {
Map<String, Object> body = new LinkedHashMap<>();
body.put("state", gate.getState().name());
body.put("invalidReason", gate.getInvalidReason());
body.put("envelope", gate.getCurrent()); // null when ABSENT/INVALID; raw token deliberately omitted
repo.findByTenantId(licenseService.getTenantId()).ifPresent(rec ->
body.put("lastValidatedAt", rec.lastValidatedAt().toString()));
return ResponseEntity.ok(body);
}
record UpdateLicenseRequest(String token) {}
public record UpdateLicenseRequest(String token) {}
@PostMapping
@Operation(summary = "Update license token at runtime")
public ResponseEntity<?> update(@RequestBody UpdateLicenseRequest request) {
if (licensePublicKey == null || licensePublicKey.isBlank()) {
return ResponseEntity.badRequest().body(Map.of("error", "No license public key configured"));
}
@Operation(summary = "Install or replace the license token at runtime")
public ResponseEntity<?> update(@RequestBody UpdateLicenseRequest request, Authentication auth) {
String userId = auth == null ? "system" : auth.getName().replaceFirst("^user:", "");
try {
LicenseValidator validator = new LicenseValidator(licensePublicKey);
LicenseInfo info = validator.validate(request.token());
licenseGate.load(info);
return ResponseEntity.ok(info);
LicenseInfo info = licenseService.install(request.token(), userId, "api");
return ResponseEntity.ok(Map.of(
"state", gate.getState().name(),
"envelope", info));
} catch (Exception e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}

View File

@@ -0,0 +1,97 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.app.license.LicenseMessageRenderer;
import com.cameleer.server.app.license.LicenseRepository;
import com.cameleer.server.app.license.LicenseService;
import com.cameleer.server.app.license.LicenseUsageReader;
import com.cameleer.server.core.agent.AgentRegistryService;
import com.cameleer.server.core.license.LicenseGate;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Read-only operator surface returning current license state, key timestamps, the
* human-readable message produced by {@link LicenseMessageRenderer}, and a per-limit
* usage/cap/source table covering every key exposed by the effective limits map.
*
* <p>Each limit row carries:
* <ul>
* <li>{@code key} — the limit key (e.g. {@code max_apps})</li>
* <li>{@code current} — current usage (0 when not measured server-side)</li>
* <li>{@code cap} — effective cap (license override or default-tier value)</li>
* <li>{@code source} — {@code "license"} when the cap came from the license override map,
* {@code "default"} otherwise</li>
* </ul>
*
* <p>{@code max_agents} is sourced from the in-memory {@link AgentRegistryService} since the
* registry is not persisted; all other counts come from PostgreSQL via
* {@link LicenseUsageReader#snapshot()}.</p>
*/
@RestController
@RequestMapping("/api/v1/admin/license/usage")
@PreAuthorize("hasRole('ADMIN')")
public class LicenseUsageController {
private final LicenseGate gate;
private final LicenseUsageReader reader;
private final AgentRegistryService agents;
private final LicenseService svc;
private final LicenseRepository repo;
public LicenseUsageController(LicenseGate gate,
LicenseUsageReader reader,
AgentRegistryService agents,
LicenseService svc,
LicenseRepository repo) {
this.gate = gate;
this.reader = reader;
this.agents = agents;
this.svc = svc;
this.repo = repo;
}
@GetMapping
public ResponseEntity<Map<String, Object>> get() {
var state = gate.getState();
var info = gate.getCurrent();
var effective = gate.getEffectiveLimits();
Map<String, Long> usage = new HashMap<>(reader.snapshot());
usage.put("max_agents", (long) agents.liveCount());
List<Map<String, Object>> limitRows = new ArrayList<>();
for (var key : effective.values().keySet()) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("key", key);
row.put("current", usage.getOrDefault(key, 0L));
row.put("cap", effective.get(key));
row.put("source", info != null && info.limits().containsKey(key) ? "license" : "default");
limitRows.add(row);
}
Map<String, Object> body = new LinkedHashMap<>();
body.put("state", state.name());
body.put("expiresAt", info == null ? null : info.expiresAt().toString());
body.put("daysRemaining", info == null ? null
: Duration.between(Instant.now(), info.expiresAt()).toDays());
body.put("gracePeriodDays", info == null ? 0 : info.gracePeriodDays());
body.put("tenantId", info == null ? null : info.tenantId());
body.put("label", info == null ? null : info.label());
repo.findByTenantId(svc.getTenantId()).ifPresent(rec ->
body.put("lastValidatedAt", rec.lastValidatedAt().toString()));
body.put("message", LicenseMessageRenderer.forState(state, info, gate.getInvalidReason()));
body.put("limits", limitRows);
return ResponseEntity.ok(body);
}
}

View File

@@ -44,6 +44,7 @@ public class LogQueryController {
@RequestParam(required = false) String exchangeId,
@RequestParam(required = false) String logger,
@RequestParam(required = false) String source,
@RequestParam(required = false) String instanceIds,
@RequestParam(required = false) String from,
@RequestParam(required = false) String to,
@RequestParam(required = false) String cursor,
@@ -69,12 +70,21 @@ public class LogQueryController {
.toList();
}
List<String> instanceIdList = List.of();
if (instanceIds != null && !instanceIds.isEmpty()) {
instanceIdList = Arrays.stream(instanceIds.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.toList();
}
Instant fromInstant = from != null ? Instant.parse(from) : null;
Instant toInstant = to != null ? Instant.parse(to) : null;
LogSearchRequest request = new LogSearchRequest(
searchText, levels, application, instanceId, exchangeId,
logger, env.slug(), sources, fromInstant, toInstant, cursor, limit, sort);
logger, env.slug(), sources, fromInstant, toInstant, cursor, limit, sort,
instanceIdList);
LogSearchResponse result = logIndex.search(request);

View File

@@ -132,13 +132,12 @@ public class RouteCatalogController {
List<AgentInfo> agents = agentsByApp.getOrDefault(appId, List.of());
Set<String> routeIds = routesByApp.getOrDefault(appId, Set.of());
List<String> agentIds = agents.stream().map(AgentInfo::instanceId).toList();
List<RouteSummary> routeSummaries = routeIds.stream()
.map(routeId -> {
String key = appId + "/" + routeId;
long count = routeExchangeCounts.getOrDefault(key, 0L);
Instant lastSeen = routeLastSeen.get(key);
String fromUri = resolveFromEndpointUri(routeId, agentIds);
String fromUri = resolveFromEndpointUri(appId, routeId, envSlug);
String state = routeStateRegistry.getState(appId, routeId).name().toLowerCase();
String routeState = "started".equals(state) ? null : state;
return new RouteSummary(routeId, count, lastSeen, fromUri, routeState);
@@ -160,8 +159,8 @@ public class RouteCatalogController {
return ResponseEntity.ok(catalog);
}
private String resolveFromEndpointUri(String routeId, List<String> agentIds) {
return diagramStore.findContentHashForRouteByAgents(routeId, agentIds)
private String resolveFromEndpointUri(String applicationId, String routeId, String environment) {
return diagramStore.findLatestContentHashForAppRoute(applicationId, routeId, environment)
.flatMap(diagramStore::findByContentHash)
.map(RouteGraph::getRoot)
.map(root -> root.getEndpointUri())

View File

@@ -4,6 +4,7 @@ import com.cameleer.server.app.web.EnvPath;
import com.cameleer.server.core.admin.AppSettings;
import com.cameleer.server.core.admin.AppSettingsRepository;
import com.cameleer.server.core.runtime.Environment;
import com.cameleer.server.core.search.AttributeFilter;
import com.cameleer.server.core.search.ExecutionStats;
import com.cameleer.server.core.search.ExecutionSummary;
import com.cameleer.server.core.search.SearchRequest;
@@ -14,6 +15,7 @@ import com.cameleer.server.core.search.TopError;
import com.cameleer.server.core.storage.StatsStore;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
@@ -21,8 +23,10 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@@ -57,11 +61,19 @@ public class SearchController {
@RequestParam(name = "agentId", required = false) String instanceId,
@RequestParam(required = false) String processorType,
@RequestParam(required = false) String application,
@RequestParam(name = "attr", required = false) List<String> attr,
@RequestParam(defaultValue = "0") int offset,
@RequestParam(defaultValue = "50") int limit,
@RequestParam(required = false) String sortField,
@RequestParam(required = false) String sortDir) {
List<AttributeFilter> attributeFilters;
try {
attributeFilters = parseAttrParams(attr);
} catch (IllegalArgumentException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage(), e);
}
SearchRequest request = new SearchRequest(
status, timeFrom, timeTo,
null, null,
@@ -72,12 +84,36 @@ public class SearchController {
offset, limit,
sortField, sortDir,
null,
env.slug()
env.slug(),
attributeFilters
);
return ResponseEntity.ok(searchService.search(request));
}
/**
* Parses {@code attr} query params of the form {@code key} (key-only) or {@code key:value}
* (exact or wildcard via {@code *}). Splits on the first {@code :}; later colons are part of
* the value. Blank / null list → empty result. Key validation is delegated to
* {@link AttributeFilter}'s compact constructor, which throws {@link IllegalArgumentException}
* on invalid keys (mapped to 400 by the caller).
*/
static List<AttributeFilter> parseAttrParams(List<String> raw) {
if (raw == null || raw.isEmpty()) return List.of();
List<AttributeFilter> out = new ArrayList<>(raw.size());
for (String entry : raw) {
if (entry == null || entry.isBlank()) continue;
int colon = entry.indexOf(':');
if (colon < 0) {
out.add(new AttributeFilter(entry.trim(), null));
} else {
out.add(new AttributeFilter(entry.substring(0, colon).trim(),
entry.substring(colon + 1)));
}
}
return out;
}
@PostMapping("/executions/search")
@Operation(summary = "Advanced search with all filters",
description = "Env from the path overrides any environment field in the body.")

View File

@@ -0,0 +1,148 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.core.storage.ServerMetricsQueryStore;
import com.cameleer.server.core.storage.model.ServerInstanceInfo;
import com.cameleer.server.core.storage.model.ServerMetricCatalogEntry;
import com.cameleer.server.core.storage.model.ServerMetricQueryRequest;
import com.cameleer.server.core.storage.model.ServerMetricQueryResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
import java.util.List;
import java.util.Map;
/**
* Generic read API over the ClickHouse {@code server_metrics} table. Lets
* SaaS control planes build server-health dashboards without requiring direct
* ClickHouse access.
*
* <p>Three endpoints cover all 17 panels in {@code docs/server-self-metrics.md}:
* <ul>
* <li>{@code GET /catalog} — discover available metric names, types, statistics, and tags</li>
* <li>{@code POST /query} — generic time-series query with aggregation, grouping, filtering, and counter-delta mode</li>
* <li>{@code GET /instances} — list server instances (useful for partitioning counter math)</li>
* </ul>
*
* <p>Visibility matches {@code ClickHouseAdminController} / {@code DatabaseAdminController}:
* <ul>
* <li>Conditional on {@code cameleer.server.security.infrastructureendpoints=true} (default).</li>
* <li>Class-level {@code @PreAuthorize("hasRole('ADMIN')")} on top of the
* {@code /api/v1/admin/**} catch-all in {@code SecurityConfig}.</li>
* </ul>
*/
@ConditionalOnProperty(
name = "cameleer.server.security.infrastructureendpoints",
havingValue = "true",
matchIfMissing = true
)
@RestController
@RequestMapping("/api/v1/admin/server-metrics")
@PreAuthorize("hasRole('ADMIN')")
@Tag(name = "Server Self-Metrics",
description = "Read API over the server's own Micrometer registry snapshots (ADMIN only)")
public class ServerMetricsAdminController {
/** Default lookback window for catalog/instances when from/to are omitted. */
private static final long DEFAULT_LOOKBACK_SECONDS = 3_600L;
private final ServerMetricsQueryStore store;
public ServerMetricsAdminController(ServerMetricsQueryStore store) {
this.store = store;
}
@GetMapping("/catalog")
@Operation(summary = "List metric names observed in the window",
description = "For each metric_name, returns metric_type, the set of statistics emitted, and the union of tag keys.")
public ResponseEntity<List<ServerMetricCatalogEntry>> catalog(
@RequestParam(required = false) String from,
@RequestParam(required = false) String to) {
Instant[] window = resolveWindow(from, to);
return ResponseEntity.ok(store.catalog(window[0], window[1]));
}
@GetMapping("/instances")
@Operation(summary = "List server_instance_id values observed in the window",
description = "Returns first/last seen timestamps — use to partition counter-delta computations.")
public ResponseEntity<List<ServerInstanceInfo>> instances(
@RequestParam(required = false) String from,
@RequestParam(required = false) String to) {
Instant[] window = resolveWindow(from, to);
return ResponseEntity.ok(store.listInstances(window[0], window[1]));
}
@PostMapping("/query")
@Operation(summary = "Generic time-series query",
description = "Returns bucketed series for a single metric_name. Supports aggregation (avg/sum/max/min/latest), group-by-tag, filter-by-tag, counter delta mode, and a derived 'mean' statistic for timers.")
public ResponseEntity<ServerMetricQueryResponse> query(@RequestBody QueryBody body) {
ServerMetricQueryRequest request = new ServerMetricQueryRequest(
body.metric(),
body.statistic(),
parseInstant(body.from(), "from"),
parseInstant(body.to(), "to"),
body.stepSeconds(),
body.groupByTags(),
body.filterTags(),
body.aggregation(),
body.mode(),
body.serverInstanceIds());
return ResponseEntity.ok(store.query(request));
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Map<String, String>> handleBadRequest(IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
private static Instant[] resolveWindow(String from, String to) {
Instant toI = to != null ? parseInstant(to, "to") : Instant.now();
Instant fromI = from != null
? parseInstant(from, "from")
: toI.minusSeconds(DEFAULT_LOOKBACK_SECONDS);
if (!fromI.isBefore(toI)) {
throw new IllegalArgumentException("from must be strictly before to");
}
return new Instant[]{fromI, toI};
}
private static Instant parseInstant(String raw, String field) {
if (raw == null || raw.isBlank()) {
throw new IllegalArgumentException(field + " is required");
}
try {
return Instant.parse(raw);
} catch (Exception e) {
throw new IllegalArgumentException(
field + " must be an ISO-8601 instant (e.g. 2026-04-23T10:00:00Z)");
}
}
/**
* Request body for {@link #query(QueryBody)}. Uses ISO-8601 strings on
* the wire so the OpenAPI schema stays language-neutral.
*/
public record QueryBody(
String metric,
String statistic,
String from,
String to,
Integer stepSeconds,
List<String> groupByTags,
Map<String, String> filterTags,
String aggregation,
String mode,
List<String> serverInstanceIds
) {
}
}

View File

@@ -1,6 +1,7 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.app.dto.SetPasswordRequest;
import com.cameleer.server.app.license.LicenseEnforcer;
import com.cameleer.server.core.admin.AuditCategory;
import com.cameleer.server.core.admin.AuditResult;
import com.cameleer.server.core.admin.AuditService;
@@ -52,13 +53,16 @@ public class UserAdminController {
private final RbacService rbacService;
private final UserRepository userRepository;
private final AuditService auditService;
private final LicenseEnforcer licenseEnforcer;
private final boolean oidcEnabled;
public UserAdminController(RbacService rbacService, UserRepository userRepository,
AuditService auditService, SecurityProperties securityProperties) {
AuditService auditService, SecurityProperties securityProperties,
LicenseEnforcer licenseEnforcer) {
this.rbacService = rbacService;
this.userRepository = userRepository;
this.auditService = auditService;
this.licenseEnforcer = licenseEnforcer;
String issuer = securityProperties.getOidc().getIssuerUri();
this.oidcEnabled = issuer != null && !issuer.isBlank();
}
@@ -89,6 +93,9 @@ public class UserAdminController {
@ApiResponse(responseCode = "400", description = "Disabled in OIDC mode")
public ResponseEntity<?> createUser(@RequestBody CreateUserRequest request,
HttpServletRequest httpRequest) {
// License cap fires first so over-cap creates short-circuit before any other validation.
// Audit emission for the rejection is handled inside LicenseEnforcer (3-arg ctor wires AuditService).
licenseEnforcer.assertWithinCap("max_users", userRepository.count(), 1);
if (oidcEnabled) {
return ResponseEntity.badRequest()
.body(Map.of("error", "Local user creation is disabled when OIDC is enabled. Users are provisioned automatically via SSO."));

View File

@@ -0,0 +1,24 @@
package com.cameleer.server.app.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
@Schema(description = "Authentication capabilities reported to the SPA so it can render the login page deterministically")
public record AuthCapabilitiesResponse(
@Schema(description = "OIDC interactive login capability") Oidc oidc,
@Schema(description = "Local username/password account capability") LocalAccounts localAccounts
) {
@Schema(description = "OIDC interactive login")
public record Oidc(
@Schema(description = "Whether OIDC is configured AND enabled") boolean enabled,
@Schema(description = "Best-effort display label, e.g. \"Logto\", \"Keycloak\", \"Single Sign-On\"") @NotNull String providerName,
@Schema(description = "When true, OIDC is the canonical entry point and the SPA hides the local form unless ?local is set") boolean primary
) {}
@Schema(description = "Local username/password accounts")
public record LocalAccounts(
@Schema(description = "Whether the local form is reachable at all") boolean enabled,
@Schema(description = "When true, the SPA gates the local form behind ?local with an admin-recovery banner") boolean adminRecoveryOnly
) {}
}

View File

@@ -0,0 +1,12 @@
package com.cameleer.server.app.dto;
import com.cameleer.server.core.runtime.DirtyStateResult;
import java.util.List;
public record DirtyStateResponse(
boolean dirty,
String lastSuccessfulDeploymentId,
List<DirtyStateResult.Difference> differences
) {
}

View File

@@ -6,8 +6,10 @@ import com.cameleer.server.core.admin.AuditService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.servlet.HandlerInterceptor;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -22,7 +24,9 @@ import java.util.Set;
public class AuditInterceptor implements HandlerInterceptor {
private static final Set<String> AUDITABLE_METHODS = Set.of("POST", "PUT", "DELETE");
private static final Set<String> EXCLUDED_PATHS = Set.of("/api/v1/search/executions");
private static final List<String> EXCLUDED_PATH_PATTERNS = List.of(
"/api/v1/environments/*/executions/search");
private static final AntPathMatcher PATH_MATCHER = new AntPathMatcher();
private final AuditService auditService;
@@ -41,8 +45,10 @@ public class AuditInterceptor implements HandlerInterceptor {
}
String path = request.getRequestURI();
if (EXCLUDED_PATHS.contains(path)) {
return;
for (String pattern : EXCLUDED_PATH_PATTERNS) {
if (PATH_MATCHER.match(pattern, path)) {
return;
}
}
AuditResult result = response.getStatus() < 400 ? AuditResult.SUCCESS : AuditResult.FAILURE;

View File

@@ -0,0 +1,18 @@
package com.cameleer.server.app.license;
public class LicenseCapExceededException extends RuntimeException {
private final String limitKey;
private final long current;
private final long cap;
public LicenseCapExceededException(String limitKey, long current, long cap) {
super("license cap reached: " + limitKey + " current=" + current + " cap=" + cap);
this.limitKey = limitKey;
this.current = current;
this.cap = cap;
}
public String limitKey() { return limitKey; }
public long current() { return current; }
public long cap() { return cap; }
}

View File

@@ -0,0 +1,12 @@
package com.cameleer.server.app.license;
import com.cameleer.server.core.license.LicenseInfo;
import com.cameleer.server.core.license.LicenseState;
import java.util.Objects;
public record LicenseChangedEvent(LicenseState state, LicenseInfo current) {
public LicenseChangedEvent {
Objects.requireNonNull(state);
}
}

View File

@@ -0,0 +1,80 @@
package com.cameleer.server.app.license;
import com.cameleer.server.core.admin.AuditCategory;
import com.cameleer.server.core.admin.AuditResult;
import com.cameleer.server.core.admin.AuditService;
import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.server.core.license.LicenseLimits;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
/**
* Single entry point for license cap enforcement (spec §4).
*
* <p>Consults {@link LicenseGate#getEffectiveLimits()} (license-overrides UNION default tier when
* ACTIVE/GRACE; defaults-only otherwise) and rejects calls whose projected usage would exceed the
* cap. Rejections increment a per-limit Micrometer counter and, when an {@link AuditService} is
* wired, emit an {@link AuditCategory#LICENSE} {@code cap_exceeded} audit row.</p>
*
* <p>Unknown limit keys are treated as programmer errors and surface as
* {@link IllegalArgumentException} (propagated from {@link LicenseLimits#get(String)}), not
* {@link LicenseCapExceededException}.</p>
*/
@Component
public class LicenseEnforcer {
private static final Logger log = LoggerFactory.getLogger(LicenseEnforcer.class);
private static final String COUNTER_NAME = "cameleer_license_cap_rejections_total";
private final LicenseGate gate;
private final MeterRegistry meters;
private final AuditService audit;
private final ConcurrentMap<String, Counter> rejectionCounters = new ConcurrentHashMap<>();
@Autowired
public LicenseEnforcer(LicenseGate gate, MeterRegistry meters, AuditService audit) {
this.gate = gate;
this.meters = meters;
this.audit = audit;
}
/** Test-only ctor with no metrics or audit. */
public LicenseEnforcer(LicenseGate gate) {
this(gate, new SimpleMeterRegistry(), null);
}
public void assertWithinCap(String limitKey, long currentUsage, long requestedDelta) {
LicenseLimits effective = gate.getEffectiveLimits();
int cap = effective.get(limitKey); // throws IllegalArgumentException if unknown key
long projected = currentUsage + requestedDelta;
if (projected > cap) {
rejectionCounters.computeIfAbsent(limitKey, k -> Counter.builder(COUNTER_NAME)
.tag("limit", k).register(meters)).increment();
if (audit != null) {
try {
Map<String, Object> detail = new LinkedHashMap<>();
detail.put("limit", limitKey);
detail.put("current", currentUsage);
detail.put("requested", requestedDelta);
detail.put("cap", cap);
detail.put("state", gate.getState().name());
audit.log("system", "cap_exceeded", AuditCategory.LICENSE, limitKey, detail, AuditResult.FAILURE, null);
} catch (RuntimeException e) {
// Audit storage degraded; log and continue so the cap rejection still surfaces as 403.
log.warn("Failed to write cap_exceeded audit row for limit={}: {}", limitKey, e.toString());
}
}
throw new LicenseCapExceededException(limitKey, projected, cap);
}
}
}

View File

@@ -0,0 +1,36 @@
package com.cameleer.server.app.license;
import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.server.core.license.LicenseInfo;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import java.util.LinkedHashMap;
import java.util.Map;
@ControllerAdvice
public class LicenseExceptionAdvice {
private final LicenseGate gate;
public LicenseExceptionAdvice(LicenseGate gate) {
this.gate = gate;
}
@ExceptionHandler(LicenseCapExceededException.class)
public ResponseEntity<Map<String, Object>> handle(LicenseCapExceededException e) {
var state = gate.getState();
LicenseInfo info = gate.getCurrent();
String reason = gate.getInvalidReason();
Map<String, Object> body = new LinkedHashMap<>();
body.put("error", "license cap reached");
body.put("limit", e.limitKey());
body.put("current", e.current());
body.put("cap", e.cap());
body.put("state", state.name());
body.put("message", LicenseMessageRenderer.forCap(state, info, e.limitKey(), e.current(), e.cap(), reason));
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(body);
}
}

View File

@@ -0,0 +1,83 @@
package com.cameleer.server.app.license;
import com.cameleer.server.core.license.LicenseInfo;
import com.cameleer.server.core.license.LicenseState;
import java.time.Duration;
import java.time.Instant;
public final class LicenseMessageRenderer {
private LicenseMessageRenderer() {}
public static String forCap(LicenseState state, LicenseInfo info, String limit, long current, long cap) {
return forCap(state, info, limit, current, cap, null);
}
public static String forCap(LicenseState state, LicenseInfo info, String limit, long current, long cap, String invalidReason) {
switch (state) {
case ABSENT:
return "No license installed: default tier applies (cap = " + cap + " for " + limit
+ "). Install a license to raise this.";
case ACTIVE:
return "License cap reached: " + limit + " = " + cap + ". Current usage is " + current
+ ". Contact your vendor to raise the cap.";
case GRACE: {
long expiredDaysAgo = info == null ? 0 : daysSince(info.expiresAt());
long graceRemaining = info == null ? 0
: Math.max(0, info.gracePeriodDays() - expiredDaysAgo);
return "License expired " + expiredDaysAgo + " day(s) ago and is in its grace period "
+ "(ends in " + graceRemaining + " days). Cap unchanged at " + cap
+ ". Renew before grace ends.";
}
case EXPIRED: {
long expiredDaysAgo = info == null ? 0 : daysSince(info.expiresAt());
return "License expired " + expiredDaysAgo + " days ago: system reverted to default tier (cap = "
+ cap + " for " + limit + "). Current usage is " + current
+ ". Renew the license to lift the cap.";
}
case INVALID:
return "License rejected (" + (invalidReason == null ? "unknown reason" : invalidReason)
+ "): default tier applies (cap = " + cap + " for " + limit + "). Fix the license to raise this.";
default:
return "License cap reached: " + limit + " = " + cap;
}
}
/**
* State-only message used by the /usage endpoint and metrics surfaces where no specific
* cap is being checked. Mirrors forCap() phrasing but omits limit/current/cap details.
*/
public static String forState(LicenseState state, LicenseInfo info) {
return forState(state, info, null);
}
public static String forState(LicenseState state, LicenseInfo info, String invalidReason) {
switch (state) {
case ABSENT:
return "No license installed: default tier applies. Install a license to raise the caps.";
case ACTIVE:
return "License is active.";
case GRACE: {
long expiredDaysAgo = info == null ? 0 : daysSince(info.expiresAt());
long graceRemaining = info == null ? 0
: Math.max(0, info.gracePeriodDays() - expiredDaysAgo);
return "License expired " + expiredDaysAgo + " day(s) ago and is in its grace period "
+ "(ends in " + graceRemaining + " days). Renew before grace ends.";
}
case EXPIRED: {
long expiredDaysAgo = info == null ? 0 : daysSince(info.expiresAt());
return "License expired " + expiredDaysAgo + " days ago: system reverted to default tier. Renew the license to lift the caps.";
}
case INVALID:
return "License rejected (" + (invalidReason == null ? "unknown reason" : invalidReason)
+ "): default tier applies. Fix the license to raise the caps.";
default:
return "License state: " + state.name();
}
}
private static long daysSince(Instant t) {
return Math.max(0, Duration.between(t, Instant.now()).toDays());
}
}

View File

@@ -0,0 +1,77 @@
package com.cameleer.server.app.license;
import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.server.core.license.LicenseState;
import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.time.Instant;
import java.util.EnumMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
/**
* Prometheus gauges that track the live license posture.
*
* <ul>
* <li>{@code cameleer_license_state{state=...}} — one-hot per {@link LicenseState}, exactly
* one tag value carries 1.0 at any time.</li>
* <li>{@code cameleer_license_days_remaining} — days until {@code expiresAt}; negative
* (-1.0) when ABSENT/INVALID (no license loaded).</li>
* <li>{@code cameleer_license_last_validated_age_seconds} — seconds since the persisted
* {@code last_validated_at}; 0 when there is no DB row.</li>
* </ul>
*
* <p>Refreshed eagerly on {@link LicenseChangedEvent} and lazily every 60 seconds so values
* stay current even without explicit state changes (e.g. days_remaining ticks down across
* the day, validated_age grows monotonically).</p>
*/
@Component
public class LicenseMetrics {
private final LicenseGate gate;
private final LicenseRepository repo;
private final String tenantId;
private final Map<LicenseState, AtomicReference<Double>> stateGauges = new EnumMap<>(LicenseState.class);
private final AtomicReference<Double> daysRemaining = new AtomicReference<>(0.0);
private final AtomicReference<Double> validatedAge = new AtomicReference<>(0.0);
public LicenseMetrics(LicenseGate gate, LicenseRepository repo, MeterRegistry meters,
@Value("${cameleer.server.tenant.id:default}") String tenantId) {
this.gate = gate;
this.repo = repo;
this.tenantId = tenantId;
for (var s : LicenseState.values()) {
var ref = new AtomicReference<>(0.0);
stateGauges.put(s, ref);
Gauge.builder("cameleer_license_state", ref, AtomicReference::get)
.tag("state", s.name())
.register(meters);
}
Gauge.builder("cameleer_license_days_remaining", daysRemaining, AtomicReference::get)
.register(meters);
Gauge.builder("cameleer_license_last_validated_age_seconds", validatedAge, AtomicReference::get)
.register(meters);
}
@EventListener(LicenseChangedEvent.class)
@Scheduled(fixedDelay = 60_000)
public void refresh() {
var state = gate.getState();
for (var s : LicenseState.values()) {
stateGauges.get(s).set(s == state ? 1.0 : 0.0);
}
var info = gate.getCurrent();
daysRemaining.set(info == null
? -1.0
: (double) Duration.between(Instant.now(), info.expiresAt()).toDays());
repo.findByTenantId(tenantId).ifPresent(rec ->
validatedAge.set((double) Duration.between(rec.lastValidatedAt(), Instant.now()).toSeconds()));
}
}

View File

@@ -0,0 +1,14 @@
package com.cameleer.server.app.license;
import java.time.Instant;
import java.util.UUID;
public record LicenseRecord(
String tenantId,
String token,
UUID licenseId,
Instant installedAt,
String installedBy,
Instant expiresAt,
Instant lastValidatedAt
) {}

View File

@@ -0,0 +1,17 @@
package com.cameleer.server.app.license;
import java.time.Instant;
import java.util.Optional;
public interface LicenseRepository {
Optional<LicenseRecord> findByTenantId(String tenantId);
/** Insert or replace the row for tenantId. */
void upsert(LicenseRecord record);
/** Update last_validated_at to `now` and return rows affected (0 = no row). */
int touchValidated(String tenantId, Instant now);
/** Delete the row (used when the operator clears a license; not a public API in v1). */
int delete(String tenantId);
}

View File

@@ -0,0 +1,58 @@
package com.cameleer.server.app.license;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* Daily revalidation cron + on-startup revalidation 60s after {@link ApplicationReadyEvent}.
*
* <p>The startup tick catches ABSENT-&gt;ACTIVE transitions when the license was written to
* PostgreSQL between server starts (e.g. SaaS provisioning), and gives slow downstream
* components time to come up before the first license event fires. The daily cron ensures
* expirations and clock drift are caught even in long-running deployments.</p>
*
* <p>Both invocations call {@link LicenseService#revalidate()} which is internally idempotent
* and exception-safe; this class additionally swallows any escape so a misbehaving validator
* cannot crash the scheduler thread.</p>
*/
@Component
public class LicenseRevalidationJob {
private static final Logger log = LoggerFactory.getLogger(LicenseRevalidationJob.class);
private final LicenseService svc;
public LicenseRevalidationJob(LicenseService svc) {
this.svc = svc;
}
@EventListener(ApplicationReadyEvent.class)
@Async
public void onStartup() {
try {
Thread.sleep(60_000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
revalidate();
}
@Scheduled(cron = "0 0 3 * * *")
public void daily() {
revalidate();
}
private void revalidate() {
try {
svc.revalidate();
} catch (Exception e) {
log.error("Revalidation crashed: {}", e.getMessage());
}
}
}

View File

@@ -0,0 +1,133 @@
package com.cameleer.server.app.license;
import com.cameleer.server.core.admin.AuditCategory;
import com.cameleer.server.core.admin.AuditResult;
import com.cameleer.server.core.admin.AuditService;
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.context.ApplicationEventPublisher;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
/**
* Single mediation point for license token install / replace / revalidate.
*
* <p>Audits under {@link AuditCategory#LICENSE}, persists to PostgreSQL via
* {@link LicenseRepository}, mutates the in-memory {@link LicenseGate}, and publishes a
* {@link LicenseChangedEvent} so downstream listeners (retention policy, license metrics,
* etc.) react uniformly to every state change.</p>
*/
public class LicenseService {
private static final Logger log = LoggerFactory.getLogger(LicenseService.class);
private final String tenantId;
private final LicenseRepository repo;
private final LicenseGate gate;
private final LicenseValidator validator;
private final AuditService audit;
private final ApplicationEventPublisher events;
public LicenseService(String tenantId, LicenseRepository repo, LicenseGate gate,
LicenseValidator validator, AuditService audit,
ApplicationEventPublisher events) {
this.tenantId = tenantId;
this.repo = repo;
this.gate = gate;
this.validator = validator;
this.audit = audit;
this.events = events;
}
/** Install a token from any source (env, file, api, db). */
public LicenseInfo install(String token, String installedBy, String source) {
LicenseInfo info;
try {
info = validator.validate(token);
} catch (Exception e) {
String reason = e.getMessage();
gate.markInvalid(reason);
Map<String, Object> detail = new LinkedHashMap<>();
detail.put("reason", reason);
detail.put("source", source);
audit.log(installedBy, "reject_license", AuditCategory.LICENSE,
tenantId, detail, AuditResult.FAILURE, null);
events.publishEvent(new LicenseChangedEvent(gate.getState(), gate.getCurrent()));
throw e instanceof RuntimeException re ? re : new IllegalArgumentException(e);
}
Optional<LicenseRecord> existing = repo.findByTenantId(tenantId);
Instant now = Instant.now();
repo.upsert(new LicenseRecord(
tenantId, token, info.licenseId(),
now, installedBy, info.expiresAt(), now));
gate.load(info);
Map<String, Object> detail = new LinkedHashMap<>();
detail.put("licenseId", info.licenseId().toString());
detail.put("expiresAt", info.expiresAt().toString());
detail.put("installedBy", installedBy);
detail.put("source", source);
if (existing.isPresent()) {
detail.put("previousLicenseId", existing.get().licenseId().toString());
audit.log(installedBy, "replace_license", AuditCategory.LICENSE,
info.licenseId().toString(), detail, AuditResult.SUCCESS, null);
} else {
audit.log(installedBy, "install_license", AuditCategory.LICENSE,
info.licenseId().toString(), detail, AuditResult.SUCCESS, null);
}
events.publishEvent(new LicenseChangedEvent(gate.getState(), info));
return info;
}
/** Boot-time load: prefer env/file overrides; falls back to DB; ABSENT if none. */
public void loadInitial(Optional<String> envToken, Optional<String> fileToken) {
if (envToken.isPresent()) {
try { install(envToken.get(), "system", "env"); return; }
catch (Exception e) { log.error("env-var license rejected: {}", e.getMessage()); }
}
if (fileToken.isPresent()) {
try { install(fileToken.get(), "system", "file"); return; }
catch (Exception e) { log.error("file license rejected: {}", e.getMessage()); }
}
Optional<LicenseRecord> persisted = repo.findByTenantId(tenantId);
if (persisted.isPresent()) {
try { install(persisted.get().token(), persisted.get().installedBy(), "db"); }
catch (Exception e) { log.error("DB license rejected: {}", e.getMessage()); }
} else {
log.info("No license configured - running in default tier");
events.publishEvent(new LicenseChangedEvent(gate.getState(), null));
}
}
/** Re-run validation against the persisted token (daily job). */
public void revalidate() {
Optional<LicenseRecord> persisted = repo.findByTenantId(tenantId);
if (persisted.isEmpty()) return;
try {
LicenseInfo info = validator.validate(persisted.get().token());
repo.touchValidated(tenantId, Instant.now());
gate.load(info);
events.publishEvent(new LicenseChangedEvent(gate.getState(), info));
} catch (Exception e) {
String reason = e.getMessage();
gate.markInvalid(reason);
Map<String, Object> detail = new LinkedHashMap<>();
detail.put("licenseId", persisted.get().licenseId().toString());
detail.put("reason", reason);
audit.log("system", "revalidate_license", AuditCategory.LICENSE,
persisted.get().licenseId().toString(), detail, AuditResult.FAILURE, null);
events.publishEvent(new LicenseChangedEvent(gate.getState(), null));
log.error("Revalidation failed: {}", reason);
}
}
public String getTenantId() { return tenantId; }
}

View File

@@ -0,0 +1,88 @@
package com.cameleer.server.app.license;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Read-side usage snapshot used by the /api/v1/admin/license/usage endpoint and license metrics.
*
* <p>Counts come straight from PostgreSQL row counts; compute aggregates SUM over
* non-stopped deployments and read replica/cpu/memory from the
* {@code deployed_config_snapshot.containerConfig} JSONB sub-object. Pre-RUNNING deployments
* (STARTING with no snapshot yet) contribute defaults (1 replica, 0 cpu, 0 memory) until they
* roll forward.</p>
*
* <p>{@code max_agents} is not in PG — the registry is in-memory; callers feed the live count
* into {@link #agentCount(int)} which echoes it for assembly into the snapshot map.</p>
*/
@Component
public class LicenseUsageReader {
private final JdbcTemplate jdbc;
public LicenseUsageReader(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
public Map<String, Long> snapshot() {
Map<String, Long> out = new LinkedHashMap<>();
out.put("max_environments", count("environments"));
out.put("max_apps", count("apps"));
out.put("max_users", count("users"));
out.put("max_outbound_connections", count("outbound_connections"));
out.put("max_alert_rules", count("alert_rules"));
Map<String, Long> compute = jdbc.queryForObject(
"SELECT " +
" COALESCE(SUM(replicas * cpu_millis), 0) AS cpu, " +
" COALESCE(SUM(replicas * memory_mb), 0) AS mem, " +
" COALESCE(SUM(replicas), 0) AS reps " +
"FROM ( " +
" SELECT " +
" COALESCE((d.deployed_config_snapshot->'containerConfig'->>'replicas')::int, 1) AS replicas, " +
" COALESCE((d.deployed_config_snapshot->'containerConfig'->>'cpuLimit')::int, 0) AS cpu_millis, " +
" COALESCE((d.deployed_config_snapshot->'containerConfig'->>'memoryLimitMb')::int, 0) AS memory_mb " +
" FROM deployments d " +
" WHERE d.status IN ('STARTING','RUNNING','DEGRADED','STOPPING') " +
") s",
(rs, n) -> Map.of(
"max_total_cpu_millis", rs.getLong("cpu"),
"max_total_memory_mb", rs.getLong("mem"),
"max_total_replicas", rs.getLong("reps")
));
out.putAll(compute);
return out;
}
/**
* Compute-cap usage tuple consumed by {@code DeploymentExecutor} pre-flight enforcement.
* Sums over all non-stopped deployments.
*/
public record ComputeUsage(long cpuMillis, long memoryMb, long replicas) {}
/**
* Convenience accessor over {@link #snapshot()} that returns just the three compute
* aggregates as a typed tuple. Used by {@code DeploymentExecutor.executeAsync} to feed
* {@code LicenseEnforcer.assertWithinCap} for the {@code max_total_cpu_millis} /
* {@code max_total_memory_mb} / {@code max_total_replicas} caps. Each call re-reads PG
* — there is no caching, so cap checks always see the latest committed state.
*/
public ComputeUsage computeUsage() {
Map<String, Long> snap = snapshot();
return new ComputeUsage(
snap.getOrDefault("max_total_cpu_millis", 0L),
snap.getOrDefault("max_total_memory_mb", 0L),
snap.getOrDefault("max_total_replicas", 0L));
}
/** Echoes the live agent count fed in by the controller (registry is in-memory). */
public long agentCount(int liveAgents) {
return liveAgents;
}
private long count(String table) {
return jdbc.queryForObject("SELECT COUNT(*) FROM " + table, Long.class);
}
}

View File

@@ -0,0 +1,66 @@
package com.cameleer.server.app.license;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.Optional;
import java.util.UUID;
public class PostgresLicenseRepository implements LicenseRepository {
private final JdbcTemplate jdbc;
public PostgresLicenseRepository(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
private static final RowMapper<LicenseRecord> MAPPER = (rs, n) -> new LicenseRecord(
rs.getString("tenant_id"),
rs.getString("token"),
(UUID) rs.getObject("license_id"),
rs.getTimestamp("installed_at").toInstant(),
rs.getString("installed_by"),
rs.getTimestamp("expires_at").toInstant(),
rs.getTimestamp("last_validated_at").toInstant()
);
@Override
public Optional<LicenseRecord> findByTenantId(String tenantId) {
return jdbc.query(
"SELECT tenant_id, token, license_id, installed_at, installed_by, expires_at, last_validated_at " +
"FROM license WHERE tenant_id = ?",
MAPPER, tenantId).stream().findFirst();
}
@Override
public void upsert(LicenseRecord r) {
jdbc.update(
"INSERT INTO license (tenant_id, token, license_id, installed_at, installed_by, expires_at, last_validated_at) " +
"VALUES (?, ?, ?, ?, ?, ?, ?) " +
"ON CONFLICT (tenant_id) DO UPDATE SET " +
" token = EXCLUDED.token, " +
" license_id = EXCLUDED.license_id, " +
" installed_at = EXCLUDED.installed_at, " +
" installed_by = EXCLUDED.installed_by, " +
" expires_at = EXCLUDED.expires_at, " +
" last_validated_at = EXCLUDED.last_validated_at",
r.tenantId(), r.token(), r.licenseId(),
Timestamp.from(r.installedAt()), r.installedBy(),
Timestamp.from(r.expiresAt()), Timestamp.from(r.lastValidatedAt())
);
}
@Override
public int touchValidated(String tenantId, Instant now) {
return jdbc.update(
"UPDATE license SET last_validated_at = ? WHERE tenant_id = ?",
Timestamp.from(now), tenantId);
}
@Override
public int delete(String tenantId) {
return jdbc.update("DELETE FROM license WHERE tenant_id = ?", tenantId);
}
}

View File

@@ -0,0 +1,119 @@
package com.cameleer.server.app.license;
import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.server.core.license.LicenseLimits;
import com.cameleer.server.core.runtime.Environment;
import com.cameleer.server.core.runtime.EnvironmentRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.event.EventListener;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* Recomputes ClickHouse per-environment TTL on every {@link LicenseChangedEvent}.
*
* <p>Spec §4.3 — when a license is installed, replaced, or expires, the effective
* retention cap may change. For each (table, env) pair this listener emits one
* {@code ALTER TABLE … MODIFY TTL <expr> WHERE environment = '<slug>'} statement
* with {@code effective = min(licenseCap, env.configuredRetentionDays)}.</p>
*
* <p>ClickHouse 22.3+ supports per-row TTL via the {@code WHERE} predicate; the
* project's CH version (24.12) is well above that floor. ClickHouse failures are
* logged and swallowed — TTL recompute is best-effort and must not propagate
* to the originating license install/revalidate path.</p>
*
* <p>NOTE: {@code route_diagrams} has no TTL clause in {@code init.sql} — it's a
* {@code ReplacingMergeTree} keyed on content_hash, not a time-series table —
* so it is intentionally excluded here. {@code server_metrics} has no
* {@code environment} column (server-wide) so it is also excluded; its 90-day
* cap is fixed in the schema.</p>
*/
@Component
public class RetentionPolicyApplier {
private static final Logger log = LoggerFactory.getLogger(RetentionPolicyApplier.class);
/** (table, time column, license cap key, env-configured-days extractor). */
private record TableSpec(String table, String timeCol, String capKey, Extractor extractor) {}
@FunctionalInterface
private interface Extractor {
int days(Environment env);
}
/**
* Tables with a TTL clause AND an {@code environment} column in {@code init.sql}.
* Verified against the schema at task time — keep in sync if new retention-bound
* tables are added.
*/
static final List<TableSpec> SPECS = List.of(
new TableSpec("executions", "start_time", "max_execution_retention_days", Environment::executionRetentionDays),
new TableSpec("processor_executions", "start_time", "max_execution_retention_days", Environment::executionRetentionDays),
new TableSpec("logs", "timestamp", "max_log_retention_days", Environment::logRetentionDays),
new TableSpec("agent_metrics", "collected_at", "max_metric_retention_days", Environment::metricRetentionDays),
new TableSpec("agent_events", "timestamp", "max_metric_retention_days", Environment::metricRetentionDays)
);
private final LicenseGate gate;
private final EnvironmentRepository envRepo;
private final JdbcTemplate clickhouseJdbc;
public RetentionPolicyApplier(LicenseGate gate,
EnvironmentRepository envRepo,
@Qualifier("clickHouseJdbcTemplate") JdbcTemplate clickhouseJdbc) {
this.gate = gate;
this.envRepo = envRepo;
this.clickhouseJdbc = clickhouseJdbc;
}
@EventListener(LicenseChangedEvent.class)
@Async
public void onLicenseChanged(LicenseChangedEvent event) {
LicenseLimits limits;
try {
limits = gate.getEffectiveLimits();
} catch (Exception e) {
log.warn("Skipping TTL recompute — could not read effective limits: {}", e.getMessage());
return;
}
List<Environment> envs;
try {
envs = envRepo.findAll();
} catch (Exception e) {
log.warn("Skipping TTL recompute — could not load environments: {}", e.getMessage());
return;
}
log.info("License changed (state={}) — recomputing TTL across {} environment(s) and {} table(s)",
event.state(), envs.size(), SPECS.size());
for (Environment env : envs) {
for (TableSpec spec : SPECS) {
int cap = limits.get(spec.capKey);
int configured = spec.extractor.days(env);
int effective = Math.min(cap, configured);
// Slugs are regex-validated `^[a-z0-9][a-z0-9-]{0,63}$`, so the replacement
// is defense-in-depth — single quotes can never be present.
String envLiteral = env.slug().replace("'", "''");
String sql = "ALTER TABLE " + spec.table
+ " MODIFY TTL toDateTime(" + spec.timeCol
+ ") + INTERVAL " + effective + " DAY DELETE"
+ " WHERE environment = '" + envLiteral + "'";
try {
clickhouseJdbc.execute(sql);
log.info("Applied TTL: table={} env={} days={} (cap={}, configured={})",
spec.table, env.slug(), effective, cap, configured);
} catch (Exception e) {
log.warn("Failed to apply TTL for table={} env={}: {}",
spec.table, env.slug(), e.getMessage());
}
}
}
}
}

View File

@@ -0,0 +1,63 @@
package com.cameleer.server.app.metrics;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.UUID;
/**
* Resolves a stable identifier for this server process, used as the
* {@code server_instance_id} on every server_metrics sample. The value is
* fixed at boot, so counters restart cleanly whenever the id rotates.
*
* <p>Precedence:
* <ol>
* <li>{@code cameleer.server.instance-id} property / {@code CAMELEER_SERVER_INSTANCE_ID} env
* <li>{@code HOSTNAME} env (populated by Docker/Kubernetes)
* <li>{@link InetAddress#getLocalHost()} hostname
* <li>Random UUID (fallback — only hit when DNS and env are both silent)
* </ol>
*/
@Configuration
public class ServerInstanceIdConfig {
private static final Logger log = LoggerFactory.getLogger(ServerInstanceIdConfig.class);
@Bean("serverInstanceId")
public String serverInstanceId(
@Value("${cameleer.server.instance-id:}") String configuredId) {
if (!isBlank(configuredId)) {
log.info("Server instance id resolved from configuration: {}", configuredId);
return configuredId;
}
String hostnameEnv = System.getenv("HOSTNAME");
if (!isBlank(hostnameEnv)) {
log.info("Server instance id resolved from HOSTNAME env: {}", hostnameEnv);
return hostnameEnv;
}
try {
String localHost = InetAddress.getLocalHost().getHostName();
if (!isBlank(localHost)) {
log.info("Server instance id resolved from localhost lookup: {}", localHost);
return localHost;
}
} catch (UnknownHostException e) {
log.debug("InetAddress.getLocalHost() failed, falling back to UUID: {}", e.getMessage());
}
String fallback = UUID.randomUUID().toString();
log.warn("Server instance id could not be resolved; using random UUID {}", fallback);
return fallback;
}
private static boolean isBlank(String s) {
return s == null || s.isBlank();
}
}

View File

@@ -0,0 +1,106 @@
package com.cameleer.server.app.metrics;
import com.cameleer.server.core.storage.ServerMetricsStore;
import com.cameleer.server.core.storage.model.ServerMetricSample;
import io.micrometer.core.instrument.Measurement;
import io.micrometer.core.instrument.Meter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Tag;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Periodically snapshots every meter in the server's {@link MeterRegistry}
* and writes the result to ClickHouse via {@link ServerMetricsStore}. This
* gives us historical server-health data (buffer depths, agent transitions,
* flush latency, JVM memory, HTTP response counts, etc.) without requiring
* an external Prometheus.
*
* <p>Each Micrometer {@link Meter#measure() measurement} becomes one row, so
* a single Timer produces rows for {@code count}, {@code total_time}, and
* {@code max} each tick. Counter values are cumulative since meter
* registration (Prometheus convention) — callers compute rate() themselves.
*
* <p>Disabled via {@code cameleer.server.self-metrics.enabled=false}.
*/
@Component
@ConditionalOnProperty(
prefix = "cameleer.server.self-metrics",
name = "enabled",
havingValue = "true",
matchIfMissing = true)
public class ServerMetricsSnapshotScheduler {
private static final Logger log = LoggerFactory.getLogger(ServerMetricsSnapshotScheduler.class);
private final MeterRegistry registry;
private final ServerMetricsStore store;
private final String tenantId;
private final String serverInstanceId;
public ServerMetricsSnapshotScheduler(
MeterRegistry registry,
ServerMetricsStore store,
@Value("${cameleer.server.tenant.id:default}") String tenantId,
@Qualifier("serverInstanceId") String serverInstanceId) {
this.registry = registry;
this.store = store;
this.tenantId = tenantId;
this.serverInstanceId = serverInstanceId;
}
@Scheduled(fixedDelayString = "${cameleer.server.self-metrics.interval-ms:60000}",
initialDelayString = "${cameleer.server.self-metrics.interval-ms:60000}")
public void snapshot() {
try {
Instant now = Instant.now();
List<ServerMetricSample> batch = new ArrayList<>();
for (Meter meter : registry.getMeters()) {
Meter.Id id = meter.getId();
Map<String, String> tags = flattenTags(id.getTagsAsIterable());
String type = id.getType().name().toLowerCase();
for (Measurement m : meter.measure()) {
double v = m.getValue();
if (!Double.isFinite(v)) continue;
batch.add(new ServerMetricSample(
tenantId,
now,
serverInstanceId,
id.getName(),
type,
m.getStatistic().getTagValueRepresentation(),
v,
tags));
}
}
if (!batch.isEmpty()) {
store.insertBatch(batch);
log.debug("Persisted {} server self-metric samples", batch.size());
}
} catch (Exception e) {
log.warn("Server self-metrics snapshot failed: {}", e.getMessage());
}
}
private static Map<String, String> flattenTags(Iterable<Tag> tags) {
Map<String, String> out = new LinkedHashMap<>();
for (Tag t : tags) {
out.put(t.getKey(), t.getValue());
}
return out;
}
}

View File

@@ -1,5 +1,6 @@
package com.cameleer.server.app.outbound;
import com.cameleer.server.app.license.LicenseEnforcer;
import com.cameleer.server.core.alerting.AlertRuleRepository;
import com.cameleer.server.core.outbound.OutboundConnection;
import com.cameleer.server.core.outbound.OutboundConnectionRepository;
@@ -18,21 +19,25 @@ public class OutboundConnectionServiceImpl implements OutboundConnectionService
private final OutboundConnectionRepository repo;
private final AlertRuleRepository ruleRepo;
private final SsrfGuard ssrfGuard;
private final LicenseEnforcer licenseEnforcer;
private final String tenantId;
public OutboundConnectionServiceImpl(
OutboundConnectionRepository repo,
AlertRuleRepository ruleRepo,
SsrfGuard ssrfGuard,
LicenseEnforcer licenseEnforcer,
String tenantId) {
this.repo = repo;
this.ruleRepo = ruleRepo;
this.ssrfGuard = ssrfGuard;
this.licenseEnforcer = licenseEnforcer;
this.tenantId = tenantId;
}
@Override
public OutboundConnection create(OutboundConnection draft, String actingUserId) {
licenseEnforcer.assertWithinCap("max_outbound_connections", repo.listByTenant(tenantId).size(), 1);
assertNameUnique(draft.name(), null);
validateUrl(draft.url());
OutboundConnection c = new OutboundConnection(

View File

@@ -1,5 +1,6 @@
package com.cameleer.server.app.outbound.config;
import com.cameleer.server.app.license.LicenseEnforcer;
import com.cameleer.server.app.outbound.OutboundConnectionServiceImpl;
import com.cameleer.server.app.outbound.SsrfGuard;
import com.cameleer.server.app.outbound.crypto.SecretCipher;
@@ -33,7 +34,8 @@ public class OutboundBeanConfig {
OutboundConnectionRepository repo,
AlertRuleRepository ruleRepo,
SsrfGuard ssrfGuard,
LicenseEnforcer licenseEnforcer,
@Value("${cameleer.server.tenant.id:default}") String tenantId) {
return new OutboundConnectionServiceImpl(repo, ruleRepo, ssrfGuard, tenantId);
return new OutboundConnectionServiceImpl(repo, ruleRepo, ssrfGuard, licenseEnforcer, tenantId);
}
}

View File

@@ -1,6 +1,10 @@
package com.cameleer.server.app.runtime;
import com.cameleer.common.model.ApplicationConfig;
import com.cameleer.server.app.license.LicenseEnforcer;
import com.cameleer.server.app.license.LicenseUsageReader;
import com.cameleer.server.app.metrics.ServerMetrics;
import com.cameleer.server.app.storage.PostgresApplicationConfigRepository;
import com.cameleer.server.app.storage.PostgresDeploymentRepository;
import com.cameleer.server.core.runtime.*;
import org.slf4j.Logger;
@@ -25,6 +29,9 @@ public class DeploymentExecutor {
private final EnvironmentService envService;
private final DeploymentRepository deploymentRepository;
private final PostgresDeploymentRepository pgDeployRepo;
private final PostgresApplicationConfigRepository applicationConfigRepository;
private final LicenseEnforcer licenseEnforcer;
private final LicenseUsageReader licenseUsageReader;
@Autowired(required = false)
private DockerNetworkManager networkManager;
@@ -59,6 +66,9 @@ public class DeploymentExecutor {
@Value("${cameleer.server.runtime.serverurl:}")
private String globalServerUrl;
@Value("${cameleer.server.runtime.certresolver:}")
private String globalCertResolver;
@Value("${cameleer.server.runtime.jardockervolume:}")
private String jarDockerVolume;
@@ -75,15 +85,49 @@ public class DeploymentExecutor {
DeploymentService deploymentService,
AppService appService,
EnvironmentService envService,
DeploymentRepository deploymentRepository) {
DeploymentRepository deploymentRepository,
PostgresApplicationConfigRepository applicationConfigRepository,
LicenseEnforcer licenseEnforcer,
LicenseUsageReader licenseUsageReader) {
this.orchestrator = orchestrator;
this.deploymentService = deploymentService;
this.appService = appService;
this.envService = envService;
this.deploymentRepository = deploymentRepository;
this.pgDeployRepo = (PostgresDeploymentRepository) deploymentRepository;
this.applicationConfigRepository = applicationConfigRepository;
this.licenseEnforcer = licenseEnforcer;
this.licenseUsageReader = licenseUsageReader;
}
/** Deployment-scoped id suffix — distinguishes container names and
* CAMELEER_AGENT_INSTANCEID across redeploys so old + new replicas can
* coexist during a blue/green swap. First 8 chars of the deployment UUID. */
static String generationOf(Deployment deployment) {
return deployment.id().toString().substring(0, 8);
}
/**
* Per-deployment context assembled once at the top of executeAsync and passed
* into strategy handlers. Keeps the strategy methods readable instead of
* threading 12 positional args.
*/
private record DeployCtx(
Deployment deployment,
App app,
Environment env,
ResolvedContainerConfig config,
String jarPath,
String resolvedRuntimeType,
String mainClass,
String generation,
String primaryNetwork,
List<String> additionalNets,
Map<String, String> baseEnvVars,
Map<String, String> prometheusLabels,
long deployStart
) {}
@Async("deploymentTaskExecutor")
public void executeAsync(Deployment deployment) {
long deployStart = System.currentTimeMillis();
@@ -91,13 +135,15 @@ public class DeploymentExecutor {
App app = appService.getById(deployment.appId());
Environment env = envService.getById(deployment.environmentId());
String jarPath = appService.resolveJarPath(deployment.appVersionId());
String generation = generationOf(deployment);
var globalDefaults = new ConfigMerger.GlobalRuntimeDefaults(
parseMemoryLimitMb(globalMemoryLimit),
globalCpuShares,
globalRoutingMode,
globalRoutingDomain,
globalServerUrl.isBlank() ? "http://cameleer-server:8081" : globalServerUrl
globalServerUrl.isBlank() ? "http://cameleer-server:8081" : globalServerUrl,
globalCertResolver.isBlank() ? null : globalCertResolver
);
ResolvedContainerConfig config = ConfigMerger.resolve(
globalDefaults, env.defaultContainerConfig(), app.containerConfig());
@@ -109,6 +155,19 @@ public class DeploymentExecutor {
updateStage(deployment.id(), DeployStage.PRE_FLIGHT);
preFlightChecks(jarPath, config);
// === LICENSE COMPUTE CAPS ===
// Spec §4.1: sum cpu/memory/replicas across non-stopped deployments + new request
// must fit within the effective tier caps. Throws LicenseCapExceededException, which
// the surrounding try/catch turns into a FAILED deployment with the cap message
// landing in deployments.error_message.
int reqCpu = config.cpuLimit() == null ? 0 : config.cpuLimit();
int reqMem = config.memoryLimitMb();
int reqReps = config.replicas();
LicenseUsageReader.ComputeUsage usage = licenseUsageReader.computeUsage();
licenseEnforcer.assertWithinCap("max_total_cpu_millis", usage.cpuMillis(), (long) reqCpu * reqReps);
licenseEnforcer.assertWithinCap("max_total_memory_mb", usage.memoryMb(), (long) reqMem * reqReps);
licenseEnforcer.assertWithinCap("max_total_replicas", usage.replicas(), reqReps);
// Resolve runtime type
String resolvedRuntimeType = config.runtimeType();
String mainClass = null;
@@ -139,7 +198,6 @@ public class DeploymentExecutor {
updateStage(deployment.id(), DeployStage.CREATE_NETWORK);
// Primary network: use configured CAMELEER_DOCKER_NETWORK (tenant-isolated in SaaS mode)
String primaryNetwork = dockerNetwork;
String envNet = null;
List<String> additionalNets = new ArrayList<>();
if (networkManager != null) {
networkManager.ensureNetwork(primaryNetwork);
@@ -147,7 +205,7 @@ public class DeploymentExecutor {
networkManager.ensureNetwork(DockerNetworkManager.TRAEFIK_NETWORK);
additionalNets.add(DockerNetworkManager.TRAEFIK_NETWORK);
// Per-environment network scoped to tenant to prevent cross-tenant collisions
envNet = DockerNetworkManager.envNetworkName(tenantId, env.slug());
String envNet = DockerNetworkManager.envNetworkName(tenantId, env.slug());
networkManager.ensureNetwork(envNet);
additionalNets.add(envNet);
}
@@ -162,111 +220,21 @@ public class DeploymentExecutor {
}
}
// === START REPLICAS ===
updateStage(deployment.id(), DeployStage.START_REPLICAS);
DeployCtx ctx = new DeployCtx(
deployment, app, env, config, jarPath,
resolvedRuntimeType, mainClass, generation,
primaryNetwork, additionalNets,
buildEnvVars(app, env, config),
PrometheusLabelBuilder.build(resolvedRuntimeType),
deployStart);
Map<String, String> baseEnvVars = buildEnvVars(app, env, config);
Map<String, String> prometheusLabels = PrometheusLabelBuilder.build(resolvedRuntimeType);
List<Map<String, Object>> replicaStates = new ArrayList<>();
List<String> newContainerIds = new ArrayList<>();
for (int i = 0; i < config.replicas(); i++) {
String instanceId = env.slug() + "-" + app.slug() + "-" + i;
String containerName = tenantId + "-" + instanceId;
// Per-replica labels (include replica index and instance-id)
Map<String, String> labels = TraefikLabelBuilder.build(app.slug(), env.slug(), tenantId, config, i);
labels.putAll(prometheusLabels);
// Per-replica env vars (set agent instance ID to match container log identity)
Map<String, String> replicaEnvVars = new LinkedHashMap<>(baseEnvVars);
replicaEnvVars.put("CAMELEER_AGENT_INSTANCEID", instanceId);
String volumeName = jarDockerVolume != null && !jarDockerVolume.isBlank() ? jarDockerVolume : null;
ContainerRequest request = new ContainerRequest(
containerName, baseImage, jarPath,
volumeName, jarStoragePath,
primaryNetwork,
additionalNets,
replicaEnvVars, labels,
config.memoryLimitBytes(), config.memoryReserveBytes(),
config.dockerCpuShares(), config.dockerCpuQuota(),
config.exposedPorts(), agentHealthPort,
"on-failure", 3,
resolvedRuntimeType, config.customArgs(), mainClass
);
String containerId = orchestrator.startContainer(request);
newContainerIds.add(containerId);
// Connect to additional networks after container is started
for (String net : additionalNets) {
if (networkManager != null) {
networkManager.connectContainer(containerId, net);
}
}
orchestrator.startLogCapture(containerId, instanceId, app.slug(), env.slug(), tenantId);
replicaStates.add(Map.of(
"index", i,
"containerId", containerId,
"containerName", containerName,
"status", "STARTING"
));
// Dispatch on strategy. Unknown values fall back to BLUE_GREEN via fromWire.
DeploymentStrategy strategy = DeploymentStrategy.fromWire(config.deploymentStrategy());
switch (strategy) {
case BLUE_GREEN -> deployBlueGreen(ctx);
case ROLLING -> deployRolling(ctx);
}
pgDeployRepo.updateReplicaStates(deployment.id(), replicaStates);
// === HEALTH CHECK ===
updateStage(deployment.id(), DeployStage.HEALTH_CHECK);
int healthyCount = waitForAnyHealthy(newContainerIds, healthCheckTimeout);
if (healthyCount == 0) {
for (String cid : newContainerIds) {
try { orchestrator.stopContainer(cid); orchestrator.removeContainer(cid); }
catch (Exception e) { log.warn("Cleanup failed for {}: {}", cid, e.getMessage()); }
}
pgDeployRepo.updateDeployStage(deployment.id(), null);
deploymentService.markFailed(deployment.id(), "No replicas passed health check within " + healthCheckTimeout + "s");
serverMetrics.recordDeploymentOutcome("FAILED");
serverMetrics.recordDeploymentDuration(deployStart);
return;
}
replicaStates = updateReplicaHealth(replicaStates, newContainerIds);
pgDeployRepo.updateReplicaStates(deployment.id(), replicaStates);
// === SWAP TRAFFIC ===
updateStage(deployment.id(), DeployStage.SWAP_TRAFFIC);
Optional<Deployment> existing = deploymentRepository.findActiveByAppIdAndEnvironmentId(
deployment.appId(), deployment.environmentId());
if (existing.isPresent() && !existing.get().id().equals(deployment.id())) {
stopDeploymentContainers(existing.get());
deploymentService.markStopped(existing.get().id());
log.info("Stopped previous deployment {} for replacement", existing.get().id());
}
// === COMPLETE ===
updateStage(deployment.id(), DeployStage.COMPLETE);
String primaryContainerId = newContainerIds.get(0);
DeploymentStatus finalStatus = healthyCount == config.replicas()
? DeploymentStatus.RUNNING : DeploymentStatus.DEGRADED;
deploymentService.markRunning(deployment.id(), primaryContainerId);
if (finalStatus == DeploymentStatus.DEGRADED) {
deploymentRepository.updateStatus(deployment.id(), DeploymentStatus.DEGRADED,
primaryContainerId, null);
}
pgDeployRepo.updateDeployStage(deployment.id(), null);
serverMetrics.recordDeploymentOutcome(finalStatus.name());
serverMetrics.recordDeploymentDuration(deployStart);
log.info("Deployment {} is {} ({}/{} replicas healthy)",
deployment.id(), finalStatus, healthyCount, config.replicas());
} catch (Exception e) {
log.error("Deployment {} FAILED: {}", deployment.id(), e.getMessage(), e);
pgDeployRepo.updateDeployStage(deployment.id(), null);
@@ -276,6 +244,262 @@ public class DeploymentExecutor {
}
}
/**
* Blue/green strategy: start all N new replicas (coexisting with the old
* ones thanks to the gen-suffixed container names), wait for ALL healthy,
* then stop the previous deployment. Strict all-healthy — partial failure
* preserves the previous deployment untouched.
*/
private void deployBlueGreen(DeployCtx ctx) {
ResolvedContainerConfig config = ctx.config();
Deployment deployment = ctx.deployment();
// === START REPLICAS ===
updateStage(deployment.id(), DeployStage.START_REPLICAS);
List<Map<String, Object>> replicaStates = new ArrayList<>();
List<String> newContainerIds = new ArrayList<>();
for (int i = 0; i < config.replicas(); i++) {
Map<String, Object> state = new LinkedHashMap<>();
String containerId = startReplica(ctx, i, state);
newContainerIds.add(containerId);
replicaStates.add(state);
}
pgDeployRepo.updateReplicaStates(deployment.id(), replicaStates);
// === HEALTH CHECK ===
updateStage(deployment.id(), DeployStage.HEALTH_CHECK);
int healthyCount = waitForAllHealthy(newContainerIds, healthCheckTimeout);
if (healthyCount < config.replicas()) {
// Strict abort: tear down new replicas, leave the previous deployment untouched.
for (String cid : newContainerIds) {
try { orchestrator.stopContainer(cid); orchestrator.removeContainer(cid); }
catch (Exception e) { log.warn("Cleanup failed for {}: {}", cid, e.getMessage()); }
}
pgDeployRepo.updateDeployStage(deployment.id(), null);
String reason = String.format(
"blue-green: %d/%d replicas healthy within %ds; preserving previous deployment",
healthyCount, config.replicas(), healthCheckTimeout);
deploymentService.markFailed(deployment.id(), reason);
serverMetrics.recordDeploymentOutcome("FAILED");
serverMetrics.recordDeploymentDuration(ctx.deployStart());
return;
}
replicaStates = updateReplicaHealth(replicaStates, newContainerIds);
pgDeployRepo.updateReplicaStates(deployment.id(), replicaStates);
// === SWAP TRAFFIC ===
// All new replicas are healthy; Traefik labels are already attracting
// traffic to them. Stop the previous deployment now — the swap is
// implicit in the label-driven load balancer.
updateStage(deployment.id(), DeployStage.SWAP_TRAFFIC);
Optional<Deployment> previous = deploymentRepository.findActiveByAppIdAndEnvironmentIdExcluding(
deployment.appId(), deployment.environmentId(), deployment.id());
if (previous.isPresent()) {
log.info("blue-green: stopping previous deployment {} now that new replicas are healthy",
previous.get().id());
stopDeploymentContainers(previous.get());
deploymentService.markStopped(previous.get().id());
}
// === COMPLETE ===
updateStage(deployment.id(), DeployStage.COMPLETE);
persistSnapshotAndMarkRunning(ctx, newContainerIds.get(0));
log.info("Deployment {} is RUNNING (blue-green, {}/{} replicas healthy)",
deployment.id(), healthyCount, config.replicas());
}
/**
* Rolling strategy: replace replicas one at a time — start new[i], wait
* healthy, stop old[i]. On any replica's health failure, stop the
* in-flight new container, leave remaining old replicas serving, mark
* FAILED. Already-replaced old containers are not restored (can't unring
* that bell) — user redeploys to recover.
*
* Resource peak: replicas + 1 (briefly while a new replica warms up
* before its counterpart is stopped).
*/
private void deployRolling(DeployCtx ctx) {
ResolvedContainerConfig config = ctx.config();
Deployment deployment = ctx.deployment();
// Capture previous deployment's per-index container ids up front.
Optional<Deployment> previousOpt = deploymentRepository.findActiveByAppIdAndEnvironmentIdExcluding(
deployment.appId(), deployment.environmentId(), deployment.id());
Map<Integer, String> oldContainerByIndex = new LinkedHashMap<>();
if (previousOpt.isPresent() && previousOpt.get().replicaStates() != null) {
for (Map<String, Object> r : previousOpt.get().replicaStates()) {
Object idx = r.get("index");
Object cid = r.get("containerId");
if (idx instanceof Number n && cid instanceof String s) {
oldContainerByIndex.put(n.intValue(), s);
}
}
}
// === START REPLICAS ===
updateStage(deployment.id(), DeployStage.START_REPLICAS);
List<Map<String, Object>> replicaStates = new ArrayList<>();
List<String> newContainerIds = new ArrayList<>();
for (int i = 0; i < config.replicas(); i++) {
// Start new replica i (gen-suffixed name; coexists with old[i]).
Map<String, Object> state = new LinkedHashMap<>();
String newCid = startReplica(ctx, i, state);
newContainerIds.add(newCid);
replicaStates.add(state);
pgDeployRepo.updateReplicaStates(deployment.id(), replicaStates);
// === HEALTH CHECK (per-replica) ===
updateStage(deployment.id(), DeployStage.HEALTH_CHECK);
boolean healthy = waitForOneHealthy(newCid, healthCheckTimeout);
if (!healthy) {
// Abort: stop this in-flight new replica AND any new replicas
// started so far. Already-stopped old replicas stay stopped
// (rolling is not reversible). Remaining un-replaced old
// replicas keep serving traffic.
for (String cid : newContainerIds) {
try { orchestrator.stopContainer(cid); orchestrator.removeContainer(cid); }
catch (Exception e) { log.warn("Cleanup failed for {}: {}", cid, e.getMessage()); }
}
pgDeployRepo.updateDeployStage(deployment.id(), null);
String reason = String.format(
"rolling: replica %d failed to reach healthy within %ds; %d previous replicas still running",
i, healthCheckTimeout, oldContainerByIndex.size());
deploymentService.markFailed(deployment.id(), reason);
serverMetrics.recordDeploymentOutcome("FAILED");
serverMetrics.recordDeploymentDuration(ctx.deployStart());
return;
}
// Health check passed: update replica status to RUNNING, stop the
// corresponding old[i] if present, and continue with replica i+1.
replicaStates = updateReplicaHealth(replicaStates, newContainerIds);
pgDeployRepo.updateReplicaStates(deployment.id(), replicaStates);
String oldCid = oldContainerByIndex.remove(i);
if (oldCid != null) {
try {
orchestrator.stopContainer(oldCid);
orchestrator.removeContainer(oldCid);
log.info("rolling: replaced replica {} (old={}, new={})", i, oldCid, newCid);
} catch (Exception e) {
log.warn("rolling: failed to stop old replica {} ({}): {}", i, oldCid, e.getMessage());
}
}
}
// === SWAP TRAFFIC ===
// Any old replicas with indices >= new.replicas (e.g., when replica
// count shrank) are still running; sweep them now so the old
// deployment can be marked STOPPED.
updateStage(deployment.id(), DeployStage.SWAP_TRAFFIC);
for (Map.Entry<Integer, String> e : oldContainerByIndex.entrySet()) {
try {
orchestrator.stopContainer(e.getValue());
orchestrator.removeContainer(e.getValue());
log.info("rolling: stopped leftover old replica {} ({})", e.getKey(), e.getValue());
} catch (Exception ex) {
log.warn("rolling: failed to stop leftover old replica {}: {}", e.getKey(), ex.getMessage());
}
}
if (previousOpt.isPresent()) {
deploymentService.markStopped(previousOpt.get().id());
}
// === COMPLETE ===
updateStage(deployment.id(), DeployStage.COMPLETE);
persistSnapshotAndMarkRunning(ctx, newContainerIds.get(0));
log.info("Deployment {} is RUNNING (rolling, {}/{} replicas replaced)",
deployment.id(), config.replicas(), config.replicas());
}
/** Poll a single container until healthy or the timeout expires. Returns
* true on healthy, false on timeout or thread interrupt. */
private boolean waitForOneHealthy(String containerId, int timeoutSeconds) {
long deadline = System.currentTimeMillis() + (timeoutSeconds * 1000L);
while (System.currentTimeMillis() < deadline) {
ContainerStatus status = orchestrator.getContainerStatus(containerId);
if ("healthy".equals(status.state())) return true;
try { Thread.sleep(2000); } catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
return false;
}
/** Start one replica container with the gen-suffixed name and return its
* container id. Fills `stateOut` with the replicaStates JSONB row. */
private String startReplica(DeployCtx ctx, int i, Map<String, Object> stateOut) {
Environment env = ctx.env();
App app = ctx.app();
ResolvedContainerConfig config = ctx.config();
String instanceId = env.slug() + "-" + app.slug() + "-" + i + "-" + ctx.generation();
String containerName = tenantId + "-" + instanceId;
Map<String, String> labels = TraefikLabelBuilder.build(
app.slug(), env.slug(), tenantId, config, i, ctx.generation());
labels.putAll(ctx.prometheusLabels());
Map<String, String> replicaEnvVars = new LinkedHashMap<>(ctx.baseEnvVars());
replicaEnvVars.put("CAMELEER_AGENT_INSTANCEID", instanceId);
String volumeName = jarDockerVolume != null && !jarDockerVolume.isBlank() ? jarDockerVolume : null;
ContainerRequest request = new ContainerRequest(
containerName, baseImage, ctx.jarPath(),
volumeName, jarStoragePath,
ctx.primaryNetwork(),
ctx.additionalNets(),
replicaEnvVars, labels,
config.memoryLimitBytes(), config.memoryReserveBytes(),
config.dockerCpuShares(), config.dockerCpuQuota(),
config.exposedPorts(), agentHealthPort,
"on-failure", 3,
ctx.resolvedRuntimeType(), config.customArgs(), ctx.mainClass()
);
String containerId = orchestrator.startContainer(request);
// Connect to additional networks after container is started
for (String net : ctx.additionalNets()) {
if (networkManager != null) {
networkManager.connectContainer(containerId, net);
}
}
orchestrator.startLogCapture(containerId, instanceId, app.slug(), env.slug(), tenantId);
stateOut.put("index", i);
stateOut.put("containerId", containerId);
stateOut.put("containerName", containerName);
stateOut.put("status", "STARTING");
return containerId;
}
/** Persist the deployment snapshot and mark the deployment RUNNING.
* Finalizes the deploy in a single place shared by all strategy paths. */
private void persistSnapshotAndMarkRunning(DeployCtx ctx, String primaryContainerId) {
Deployment deployment = ctx.deployment();
ApplicationConfig agentConfig = applicationConfigRepository
.findByApplicationAndEnvironment(ctx.app().slug(), ctx.env().slug())
.orElse(null);
List<String> snapshotSensitiveKeys = agentConfig != null ? agentConfig.getSensitiveKeys() : null;
DeploymentConfigSnapshot snapshot = new DeploymentConfigSnapshot(
deployment.appVersionId(),
agentConfig,
ctx.app().containerConfig(),
snapshotSensitiveKeys);
pgDeployRepo.saveDeployedConfigSnapshot(deployment.id(), snapshot);
deploymentService.markRunning(deployment.id(), primaryContainerId);
pgDeployRepo.updateDeployStage(deployment.id(), null);
serverMetrics.recordDeploymentOutcome("RUNNING");
serverMetrics.recordDeploymentDuration(ctx.deployStart());
}
public void stopDeployment(Deployment deployment) {
pgDeployRepo.updateTargetState(deployment.id(), "STOPPED");
deploymentRepository.updateStatus(deployment.id(), DeploymentStatus.STOPPING,
@@ -341,7 +565,10 @@ public class DeploymentExecutor {
return envVars;
}
private int waitForAnyHealthy(List<String> containerIds, int timeoutSeconds) {
/** Poll until all containers are healthy or the timeout expires. Returns
* the healthy count at return time — == ids.size() on full success, less
* if the timeout won. */
private int waitForAllHealthy(List<String> containerIds, int timeoutSeconds) {
long deadline = System.currentTimeMillis() + (timeoutSeconds * 1000L);
int lastHealthy = 0;
while (System.currentTimeMillis() < deadline) {
@@ -403,6 +630,10 @@ public class DeploymentExecutor {
map.put("runtimeType", config.runtimeType());
map.put("customArgs", config.customArgs());
map.put("extraNetworks", config.extraNetworks());
map.put("externalRouting", config.externalRouting());
if (config.certResolver() != null) {
map.put("certResolver", config.certResolver());
}
return map;
}
}

View File

@@ -7,6 +7,7 @@ import com.github.dockerjava.api.DockerClient;
import com.github.dockerjava.api.async.ResultCallback;
import com.github.dockerjava.api.model.AccessMode;
import com.github.dockerjava.api.model.Bind;
import com.github.dockerjava.api.model.Capability;
import com.github.dockerjava.api.model.Frame;
import com.github.dockerjava.api.model.HealthCheck;
import com.github.dockerjava.api.model.HostConfig;
@@ -25,12 +26,58 @@ import java.util.stream.Stream;
public class DockerRuntimeOrchestrator implements RuntimeOrchestrator {
private static final Logger log = LoggerFactory.getLogger(DockerRuntimeOrchestrator.class);
/** Sandboxed runtime we prefer when the daemon has it registered. */
private static final String SANDBOX_RUNTIME = "runsc";
/** Hard cap on processes/threads per tenant container. Spring Boot + Camel
* + a Kafka client comfortably fits in 512; raise via daemon-wide limits if
* a tenant legitimately needs more (and revisit the multi-tenancy threat
* model when that happens). */
private static final long PIDS_LIMIT = 512L;
/** /tmp must be writeable for JVM tmpdir, JIT scratch, and JNI native lib
* unpacking (Netty tcnative, Snappy, LZ4, Zstd all dlopen from here).
* `noexec` would block dlopen via mmap(PROT_EXEC) — keep it off. */
private static final String TMPFS_TMP_OPTS = "rw,nosuid,size=256m";
private final DockerClient dockerClient;
private final String dockerRuntime;
private ContainerLogForwarder logForwarder;
public DockerRuntimeOrchestrator(DockerClient dockerClient) {
this(dockerClient, "");
}
public DockerRuntimeOrchestrator(DockerClient dockerClient, String runtimeOverride) {
this.dockerClient = dockerClient;
this.dockerRuntime = resolveRuntime(runtimeOverride);
}
private String resolveRuntime(String override) {
if (override != null && !override.isBlank()) {
log.info("Container runtime forced to '{}' via cameleer.server.runtime.dockerruntime", override);
return override;
}
try {
Map<String, ?> runtimes = dockerClient.infoCmd().exec().getRuntimes();
if (runtimes != null && runtimes.containsKey(SANDBOX_RUNTIME)) {
log.info("gVisor ({}) detected — sandboxed runtime will be used for tenant containers",
SANDBOX_RUNTIME);
return SANDBOX_RUNTIME;
}
} catch (Exception e) {
log.warn("Could not query Docker runtimes: {} — falling back to daemon default", e.getMessage());
}
log.info("No sandboxed runtime detected — using Docker default (runc). Install gVisor on the host "
+ "for tenant kernel isolation; see issue #152.");
return "";
}
/** Visible for tests / introspection. Empty string = let Docker pick its default. */
String getDockerRuntime() {
return dockerRuntime;
}
public void setLogForwarder(ContainerLogForwarder logForwarder) {
@@ -68,12 +115,36 @@ public class DockerRuntimeOrchestrator implements RuntimeOrchestrator {
List<String> envList = request.envVars().entrySet().stream()
.map(e -> e.getKey() + "=" + e.getValue()).toList();
// Tenant containers run untrusted user JVMs — every tenant JAR can call
// Runtime.exec, reflective bean dispatch, MVEL/Groovy templating. Java 17
// has no SecurityManager, so isolation MUST live below the JVM.
// See issue #152 for the full threat model. Defaults are fail-closed:
// - cap_drop ALL: outbound TCP still works (no caps needed); raw sockets,
// ptrace, mounts, and bind <1024 are all denied.
// - no-new-privileges: setuid binaries cannot escalate.
// - apparmor=docker-default: Docker's stock MAC profile.
// Daemon's default seccomp profile is applied implicitly when no
// `seccomp=` override is set — no need to declare it.
// - readonly rootfs + /tmp tmpfs: persistence-via-write defeated; apps
// needing durable state declare writeableVolumes (issue #153).
// - pids-limit: fork bombs cannot exhaust the host PID namespace.
HostConfig hostConfig = HostConfig.newHostConfig()
.withMemory(request.memoryLimitBytes())
.withMemorySwap(request.memoryLimitBytes())
.withCpuShares(request.cpuShares())
.withNetworkMode(request.network())
.withRestartPolicy(RestartPolicy.onFailureRestart(request.restartPolicyMaxRetries()));
.withRestartPolicy(RestartPolicy.onFailureRestart(request.restartPolicyMaxRetries()))
.withCapDrop(Capability.values())
.withSecurityOpts(List.of(
"no-new-privileges:true",
"apparmor=docker-default"))
.withReadonlyRootfs(true)
.withPidsLimit(PIDS_LIMIT)
.withTmpFs(Map.of("/tmp", TMPFS_TMP_OPTS));
if (!dockerRuntime.isBlank()) {
hostConfig.withRuntime(dockerRuntime);
}
// JAR mounting: volume mount (Docker-in-Docker) or bind mount (host path)
if (request.jarVolumeName() != null && !request.jarVolumeName().isBlank()) {

View File

@@ -11,6 +11,7 @@ import com.github.dockerjava.zerodep.ZerodepDockerHttpClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -41,10 +42,12 @@ public class RuntimeOrchestratorAutoConfig {
@Bean
public RuntimeOrchestrator runtimeOrchestrator(
@Autowired(required = false) DockerClient dockerClient,
@Autowired(required = false) ContainerLogForwarder logForwarder) {
@Autowired(required = false) ContainerLogForwarder logForwarder,
@Value("${cameleer.server.runtime.dockerruntime:}") String dockerRuntimeOverride) {
if (dockerClient != null) {
log.info("Docker socket detected - enabling Docker runtime orchestrator");
DockerRuntimeOrchestrator orchestrator = new DockerRuntimeOrchestrator(dockerClient);
DockerRuntimeOrchestrator orchestrator =
new DockerRuntimeOrchestrator(dockerClient, dockerRuntimeOverride);
if (logForwarder != null) {
orchestrator.setLogForwarder(logForwarder);
}

View File

@@ -10,19 +10,28 @@ public final class TraefikLabelBuilder {
private TraefikLabelBuilder() {}
public static Map<String, String> build(String appSlug, String envSlug, String tenantId,
ResolvedContainerConfig config, int replicaIndex) {
ResolvedContainerConfig config, int replicaIndex,
String generation) {
// Traefik router/service keys stay generation-agnostic so load balancing
// spans old + new replicas during a blue/green overlap. instance-id and
// the new generation label carry the per-deploy identity.
String svc = envSlug + "-" + appSlug;
String instanceId = envSlug + "-" + appSlug + "-" + replicaIndex;
String instanceId = envSlug + "-" + appSlug + "-" + replicaIndex + "-" + generation;
Map<String, String> labels = new LinkedHashMap<>();
labels.put("traefik.enable", "true");
labels.put("managed-by", "cameleer-server");
labels.put("cameleer.tenant", tenantId);
labels.put("cameleer.app", appSlug);
labels.put("cameleer.environment", envSlug);
labels.put("cameleer.replica", String.valueOf(replicaIndex));
labels.put("cameleer.generation", generation);
labels.put("cameleer.instance-id", instanceId);
if (!config.externalRouting()) {
return labels;
}
labels.put("traefik.enable", "true");
labels.put("traefik.http.services." + svc + ".loadbalancer.server.port",
String.valueOf(config.appPort()));
@@ -46,7 +55,10 @@ public final class TraefikLabelBuilder {
if (config.sslOffloading()) {
labels.put("traefik.http.routers." + svc + ".tls", "true");
labels.put("traefik.http.routers." + svc + ".tls.certresolver", "default");
if (config.certResolver() != null && !config.certResolver().isBlank()) {
labels.put("traefik.http.routers." + svc + ".tls.certresolver",
config.certResolver());
}
}
return labels;

View File

@@ -122,6 +122,14 @@ public class ClickHouseLogStore implements LogIndex {
baseParams.add(request.instanceId());
}
if (!request.instanceIds().isEmpty()) {
String placeholders = String.join(", ", Collections.nCopies(request.instanceIds().size(), "?"));
baseConditions.add("instance_id IN (" + placeholders + ")");
for (String id : request.instanceIds()) {
baseParams.add(id);
}
}
if (request.exchangeId() != null && !request.exchangeId().isEmpty()) {
baseConditions.add("(exchange_id = ?" +
" OR (mapContains(mdc, 'cameleer.exchangeId') AND mdc['cameleer.exchangeId'] = ?)" +
@@ -281,6 +289,14 @@ public class ClickHouseLogStore implements LogIndex {
params.add(request.instanceId());
}
if (!request.instanceIds().isEmpty()) {
String placeholders = String.join(", ", Collections.nCopies(request.instanceIds().size(), "?"));
conditions.add("instance_id IN (" + placeholders + ")");
for (String id : request.instanceIds()) {
params.add(id);
}
}
if (request.exchangeId() != null && !request.exchangeId().isEmpty()) {
conditions.add("(exchange_id = ?" +
" OR (mapContains(mdc, 'cameleer.exchangeId') AND mdc['cameleer.exchangeId'] = ?)" +

View File

@@ -1,6 +1,7 @@
package com.cameleer.server.app.search;
import com.cameleer.server.core.alerting.AlertMatchSpec;
import com.cameleer.server.core.search.AttributeFilter;
import com.cameleer.server.core.search.ExecutionSummary;
import com.cameleer.server.core.search.SearchRequest;
import com.cameleer.server.core.search.SearchResult;
@@ -256,6 +257,23 @@ public class ClickHouseSearchIndex implements SearchIndex {
params.add(likeTerm);
}
// Structured attribute filters. Keys were validated at AttributeFilter construction
// time against ^[a-zA-Z0-9._-]+$ so they are safe to single-quote-inline; the JSON path
// argument of JSONExtractString does not accept a ? placeholder in ClickHouse JDBC
// (same constraint as countExecutionsForAlerting below). Values are parameter-bound.
for (AttributeFilter filter : request.attributeFilters()) {
String escapedKey = filter.key().replace("'", "\\'");
if (filter.isKeyOnly()) {
conditions.add("JSONHas(attributes, '" + escapedKey + "')");
} else if (filter.isWildcard()) {
conditions.add("JSONExtractString(attributes, '" + escapedKey + "') LIKE ?");
params.add(filter.toLikePattern());
} else {
conditions.add("JSONExtractString(attributes, '" + escapedKey + "') = ?");
params.add(filter.value());
}
}
return String.join(" AND ", conditions);
}

View File

@@ -3,6 +3,7 @@ package com.cameleer.server.app.security;
import com.cameleer.server.app.dto.AuthTokenResponse;
import com.cameleer.server.app.dto.ErrorResponse;
import com.cameleer.server.app.dto.OidcPublicConfigResponse;
import com.cameleer.server.app.license.LicenseEnforcer;
import com.cameleer.server.core.admin.AuditCategory;
import com.cameleer.server.core.admin.AuditResult;
import com.cameleer.server.core.admin.AuditService;
@@ -63,6 +64,7 @@ public class OidcAuthController {
private final ClaimMappingService claimMappingService;
private final ClaimMappingRepository claimMappingRepository;
private final GroupRepository groupRepository;
private final LicenseEnforcer licenseEnforcer;
public OidcAuthController(OidcTokenExchanger tokenExchanger,
OidcConfigRepository configRepository,
@@ -72,7 +74,8 @@ public class OidcAuthController {
RbacService rbacService,
ClaimMappingService claimMappingService,
ClaimMappingRepository claimMappingRepository,
GroupRepository groupRepository) {
GroupRepository groupRepository,
LicenseEnforcer licenseEnforcer) {
this.tokenExchanger = tokenExchanger;
this.configRepository = configRepository;
this.jwtService = jwtService;
@@ -82,6 +85,7 @@ public class OidcAuthController {
this.claimMappingService = claimMappingService;
this.claimMappingRepository = claimMappingRepository;
this.groupRepository = groupRepository;
this.licenseEnforcer = licenseEnforcer;
}
/**
@@ -154,6 +158,13 @@ public class OidcAuthController {
"Account not provisioned. Contact your administrator.");
}
// Auto-signup branch: when the user does not yet exist and the IdP is allowed to
// provision new accounts, enforce the max_users license cap before persisting.
// The global LicenseExceptionAdvice maps this to a structured 403 envelope.
if (existingUser.isEmpty() && config.get().autoSignup()) {
licenseEnforcer.assertWithinCap("max_users", userRepository.count(), 1);
}
userRepository.upsert(new UserInfo(
userId, provider, oidcUser.email(), oidcUser.name(), Instant.now()));

View File

@@ -0,0 +1,41 @@
package com.cameleer.server.app.security;
import java.net.URI;
/**
* Pure utility — derives a display label for an OIDC provider from its issuer URI.
* Used by {@link AuthCapabilitiesController} so the SPA can render
* "Sign in with {providerName}" on the login page.
*
* <p>Pattern-match only — never network-discover. If the issuer doesn't match a
* known vendor pattern, we return the generic "Single Sign-On" label rather than
* leaking hostnames into the UI.
*/
public final class OidcProviderNameDeriver {
private static final String GENERIC = "Single Sign-On";
private OidcProviderNameDeriver() {}
public static String deriveName(String issuerUri) {
if (issuerUri == null || issuerUri.isBlank()) {
return GENERIC;
}
String host;
try {
URI uri = URI.create(issuerUri.trim());
host = uri.getHost();
} catch (IllegalArgumentException e) {
return GENERIC;
}
if (host == null || host.isBlank()) {
return GENERIC;
}
String h = host.toLowerCase();
if (h.contains("logto")) return "Logto";
if (h.contains("keycloak")) return "Keycloak";
if (h.endsWith("auth0.com")) return "Auth0";
if (h.endsWith("okta.com") || h.endsWith("oktapreview.com")) return "Okta";
return GENERIC;
}
}

View File

@@ -16,8 +16,6 @@ import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HexFormat;
import java.util.List;
@@ -57,6 +55,12 @@ public class ClickHouseDiagramStore implements DiagramStore {
ORDER BY created_at DESC LIMIT 1
""";
private static final String SELECT_HASH_FOR_APP_ROUTE = """
SELECT content_hash FROM route_diagrams
WHERE tenant_id = ? AND application_id = ? AND environment = ? AND route_id = ?
ORDER BY created_at DESC LIMIT 1
""";
private static final String SELECT_DEFINITIONS_FOR_APP = """
SELECT DISTINCT route_id, definition FROM route_diagrams
WHERE tenant_id = ? AND application_id = ? AND environment = ?
@@ -68,6 +72,8 @@ public class ClickHouseDiagramStore implements DiagramStore {
// (routeId + "\0" + instanceId) → contentHash
private final ConcurrentHashMap<String, String> hashCache = new ConcurrentHashMap<>();
// (applicationId + "\0" + environment + "\0" + routeId) → most recent contentHash
private final ConcurrentHashMap<String, String> appRouteHashCache = new ConcurrentHashMap<>();
// contentHash → deserialized RouteGraph
private final ConcurrentHashMap<String, RouteGraph> graphCache = new ConcurrentHashMap<>();
@@ -92,12 +98,37 @@ public class ClickHouseDiagramStore implements DiagramStore {
} catch (Exception e) {
log.warn("Failed to warm diagram hash cache — lookups will fall back to ClickHouse: {}", e.getMessage());
}
try {
jdbc.query(
"SELECT application_id, environment, route_id, " +
"argMax(content_hash, created_at) AS content_hash " +
"FROM route_diagrams WHERE tenant_id = ? " +
"GROUP BY application_id, environment, route_id",
rs -> {
String key = appRouteCacheKey(
rs.getString("application_id"),
rs.getString("environment"),
rs.getString("route_id"));
appRouteHashCache.put(key, rs.getString("content_hash"));
},
tenantId);
log.info("Diagram app-route cache warmed: {} entries", appRouteHashCache.size());
} catch (Exception e) {
log.warn("Failed to warm diagram app-route cache — lookups will fall back to ClickHouse: {}", e.getMessage());
}
}
private static String cacheKey(String routeId, String instanceId) {
return routeId + "\0" + instanceId;
}
private static String appRouteCacheKey(String applicationId, String environment, String routeId) {
return (applicationId != null ? applicationId : "") + "\0"
+ (environment != null ? environment : "") + "\0"
+ (routeId != null ? routeId : "");
}
@Override
public void store(TaggedDiagram diagram) {
try {
@@ -122,6 +153,7 @@ public class ClickHouseDiagramStore implements DiagramStore {
// Update caches
hashCache.put(cacheKey(routeId, agentId), contentHash);
appRouteHashCache.put(appRouteCacheKey(applicationId, environment, routeId), contentHash);
graphCache.put(contentHash, graph);
log.debug("Stored diagram for route={} agent={} with hash={}", routeId, agentId, contentHash);
@@ -170,33 +202,29 @@ public class ClickHouseDiagramStore implements DiagramStore {
}
@Override
public Optional<String> findContentHashForRouteByAgents(String routeId, List<String> agentIds) {
if (agentIds == null || agentIds.isEmpty()) {
public Optional<String> findLatestContentHashForAppRoute(String applicationId,
String routeId,
String environment) {
if (applicationId == null || applicationId.isBlank()
|| routeId == null || routeId.isBlank()
|| environment == null || environment.isBlank()) {
return Optional.empty();
}
// Try cache first — return first hit
for (String agentId : agentIds) {
String cached = hashCache.get(cacheKey(routeId, agentId));
if (cached != null) {
return Optional.of(cached);
}
String key = appRouteCacheKey(applicationId, environment, routeId);
String cached = appRouteHashCache.get(key);
if (cached != null) {
return Optional.of(cached);
}
// Fall back to ClickHouse
String placeholders = String.join(", ", Collections.nCopies(agentIds.size(), "?"));
String sql = "SELECT content_hash FROM route_diagrams " +
"WHERE tenant_id = ? AND route_id = ? AND instance_id IN (" + placeholders + ") " +
"ORDER BY created_at DESC LIMIT 1";
var params = new ArrayList<Object>();
params.add(tenantId);
params.add(routeId);
params.addAll(agentIds);
List<Map<String, Object>> rows = jdbc.queryForList(sql, params.toArray());
List<Map<String, Object>> rows = jdbc.queryForList(
SELECT_HASH_FOR_APP_ROUTE, tenantId, applicationId, environment, routeId);
if (rows.isEmpty()) {
return Optional.empty();
}
return Optional.of((String) rows.get(0).get("content_hash"));
String hash = (String) rows.get(0).get("content_hash");
appRouteHashCache.put(key, hash);
return Optional.of(hash);
}
@Override

View File

@@ -0,0 +1,408 @@
package com.cameleer.server.app.storage;
import com.cameleer.server.core.storage.ServerMetricsQueryStore;
import com.cameleer.server.core.storage.model.ServerInstanceInfo;
import com.cameleer.server.core.storage.model.ServerMetricCatalogEntry;
import com.cameleer.server.core.storage.model.ServerMetricPoint;
import com.cameleer.server.core.storage.model.ServerMetricQueryRequest;
import com.cameleer.server.core.storage.model.ServerMetricQueryResponse;
import com.cameleer.server.core.storage.model.ServerMetricSeries;
import org.springframework.jdbc.core.JdbcTemplate;
import java.sql.Array;
import java.sql.Timestamp;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.regex.Pattern;
/**
* ClickHouse-backed {@link ServerMetricsQueryStore}.
*
* <p>Safety rules for every query:
* <ul>
* <li>tenant_id always bound as a parameter — no cross-tenant reads.</li>
* <li>Identifier-like inputs (metric name, statistic, tag keys,
* aggregation, mode) are regex-validated. Tag keys flow through the
* query as JDBC parameter-bound values of {@code tags[?]} map lookups,
* so even with a "safe" regex they cannot inject SQL.</li>
* <li>Literal values ({@code from}, {@code to}, tag filter values,
* server_instance_id allow-list) always go through {@code ?}.</li>
* <li>The time range is capped at {@link #MAX_RANGE}.</li>
* <li>Result cardinality is capped at {@link #MAX_SERIES} series.</li>
* </ul>
*/
public class ClickHouseServerMetricsQueryStore implements ServerMetricsQueryStore {
private static final Pattern SAFE_IDENTIFIER = Pattern.compile("^[a-zA-Z0-9._]+$");
private static final Pattern SAFE_STATISTIC = Pattern.compile("^[a-z_]+$");
private static final Set<String> AGGREGATIONS = Set.of("avg", "sum", "max", "min", "latest");
private static final Set<String> MODES = Set.of("raw", "delta");
/** Maximum {@code to - from} window accepted by the API. */
static final Duration MAX_RANGE = Duration.ofDays(31);
/** Clamp bounds and default for {@code stepSeconds}. */
static final int MIN_STEP = 10;
static final int MAX_STEP = 3600;
static final int DEFAULT_STEP = 60;
/** Defence against group-by explosion — limit the series count per response. */
static final int MAX_SERIES = 500;
private final String tenantId;
private final JdbcTemplate jdbc;
public ClickHouseServerMetricsQueryStore(String tenantId, JdbcTemplate jdbc) {
this.tenantId = tenantId;
this.jdbc = jdbc;
}
// ── catalog ─────────────────────────────────────────────────────────
@Override
public List<ServerMetricCatalogEntry> catalog(Instant from, Instant to) {
requireRange(from, to);
String sql = """
SELECT
metric_name,
any(metric_type) AS metric_type,
arraySort(groupUniqArray(statistic)) AS statistics,
arraySort(arrayDistinct(arrayFlatten(groupArray(mapKeys(tags))))) AS tag_keys
FROM server_metrics
WHERE tenant_id = ?
AND collected_at >= ?
AND collected_at < ?
GROUP BY metric_name
ORDER BY metric_name
""";
return jdbc.query(sql, (rs, n) -> new ServerMetricCatalogEntry(
rs.getString("metric_name"),
rs.getString("metric_type"),
arrayToStringList(rs.getArray("statistics")),
arrayToStringList(rs.getArray("tag_keys"))
), tenantId, Timestamp.from(from), Timestamp.from(to));
}
// ── instances ───────────────────────────────────────────────────────
@Override
public List<ServerInstanceInfo> listInstances(Instant from, Instant to) {
requireRange(from, to);
String sql = """
SELECT
server_instance_id,
min(collected_at) AS first_seen,
max(collected_at) AS last_seen
FROM server_metrics
WHERE tenant_id = ?
AND collected_at >= ?
AND collected_at < ?
GROUP BY server_instance_id
ORDER BY last_seen DESC
""";
return jdbc.query(sql, (rs, n) -> new ServerInstanceInfo(
rs.getString("server_instance_id"),
rs.getTimestamp("first_seen").toInstant(),
rs.getTimestamp("last_seen").toInstant()
), tenantId, Timestamp.from(from), Timestamp.from(to));
}
// ── query ───────────────────────────────────────────────────────────
@Override
public ServerMetricQueryResponse query(ServerMetricQueryRequest request) {
if (request == null) throw new IllegalArgumentException("request is required");
String metric = requireSafeIdentifier(request.metric(), "metric");
requireRange(request.from(), request.to());
String aggregation = request.aggregation() != null ? request.aggregation().toLowerCase() : "avg";
if (!AGGREGATIONS.contains(aggregation)) {
throw new IllegalArgumentException("aggregation must be one of " + AGGREGATIONS);
}
String mode = request.mode() != null ? request.mode().toLowerCase() : "raw";
if (!MODES.contains(mode)) {
throw new IllegalArgumentException("mode must be one of " + MODES);
}
int step = request.stepSeconds() != null ? request.stepSeconds() : DEFAULT_STEP;
if (step < MIN_STEP || step > MAX_STEP) {
throw new IllegalArgumentException(
"stepSeconds must be in [" + MIN_STEP + "," + MAX_STEP + "]");
}
String statistic = request.statistic();
if (statistic != null && !SAFE_STATISTIC.matcher(statistic).matches()) {
throw new IllegalArgumentException("statistic contains unsafe characters");
}
List<String> groupByTags = request.groupByTags() != null
? request.groupByTags() : List.of();
for (String t : groupByTags) requireSafeIdentifier(t, "groupByTag");
Map<String, String> filterTags = request.filterTags() != null
? request.filterTags() : Map.of();
for (String t : filterTags.keySet()) requireSafeIdentifier(t, "filterTag key");
List<String> instanceAllowList = request.serverInstanceIds() != null
? request.serverInstanceIds() : List.of();
boolean isDelta = "delta".equals(mode);
boolean isMean = "mean".equals(statistic);
String sql = isDelta
? buildDeltaSql(step, groupByTags, filterTags, instanceAllowList, statistic, isMean)
: buildRawSql(step, groupByTags, filterTags, instanceAllowList,
statistic, aggregation, isMean);
List<Object> params = buildParams(groupByTags, metric, statistic, isMean,
request.from(), request.to(),
filterTags, instanceAllowList);
List<Row> rows = jdbc.query(sql, (rs, n) -> {
int idx = 1;
Instant bucket = rs.getTimestamp(idx++).toInstant();
List<String> tagValues = new ArrayList<>(groupByTags.size());
for (int g = 0; g < groupByTags.size(); g++) {
tagValues.add(rs.getString(idx++));
}
double value = rs.getDouble(idx);
return new Row(bucket, tagValues, value);
}, params.toArray());
return assembleSeries(rows, metric, statistic, aggregation, mode, step, groupByTags);
}
// ── SQL builders ────────────────────────────────────────────────────
/**
* Builds a single-pass SQL for raw mode:
* <pre>{@code
* SELECT bucket, tag0, ..., <agg>(metric_value) AS value
* FROM server_metrics WHERE ...
* GROUP BY bucket, tag0, ...
* ORDER BY bucket, tag0, ...
* }</pre>
* For {@code statistic=mean}, replaces the aggregate with
* {@code sumIf(value, statistic IN ('total','total_time')) / nullIf(sumIf(value, statistic='count'), 0)}.
*/
private String buildRawSql(int step, List<String> groupByTags,
Map<String, String> filterTags,
List<String> instanceAllowList,
String statistic, String aggregation, boolean isMean) {
StringBuilder s = new StringBuilder(512);
s.append("SELECT\n toDateTime64(toStartOfInterval(collected_at, INTERVAL ")
.append(step).append(" SECOND), 3) AS bucket");
for (int i = 0; i < groupByTags.size(); i++) {
s.append(",\n tags[?] AS tag").append(i);
}
s.append(",\n ").append(isMean ? meanExpr() : scalarAggExpr(aggregation))
.append(" AS value\nFROM server_metrics\n");
appendWhereClause(s, filterTags, instanceAllowList, statistic, isMean);
s.append("GROUP BY bucket");
for (int i = 0; i < groupByTags.size(); i++) s.append(", tag").append(i);
s.append("\nORDER BY bucket");
for (int i = 0; i < groupByTags.size(); i++) s.append(", tag").append(i);
return s.toString();
}
/**
* Builds a three-level SQL for delta mode. Inner fills one
* (bucket, instance, tag-group) row via {@code max(metric_value)};
* middle computes positive-clipped per-instance differences via a
* window function; outer sums across instances.
*/
private String buildDeltaSql(int step, List<String> groupByTags,
Map<String, String> filterTags,
List<String> instanceAllowList,
String statistic, boolean isMean) {
StringBuilder s = new StringBuilder(1024);
s.append("SELECT bucket");
for (int i = 0; i < groupByTags.size(); i++) s.append(", tag").append(i);
s.append(", sum(delta) AS value FROM (\n");
// Middle: per-instance positive-clipped delta using window.
s.append(" SELECT bucket");
for (int i = 0; i < groupByTags.size(); i++) s.append(", tag").append(i);
s.append(", server_instance_id, greatest(0, value - coalesce(any(value) OVER (")
.append("PARTITION BY server_instance_id");
for (int i = 0; i < groupByTags.size(); i++) s.append(", tag").append(i);
s.append(" ORDER BY bucket ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING), value)) AS delta FROM (\n");
// Inner: one representative value per (bucket, instance, tag-group).
s.append(" SELECT\n toDateTime64(toStartOfInterval(collected_at, INTERVAL ")
.append(step).append(" SECOND), 3) AS bucket,\n server_instance_id");
for (int i = 0; i < groupByTags.size(); i++) {
s.append(",\n tags[?] AS tag").append(i);
}
s.append(",\n ").append(isMean ? meanExpr() : "max(metric_value)")
.append(" AS value\n FROM server_metrics\n");
appendWhereClause(s, filterTags, instanceAllowList, statistic, isMean);
s.append(" GROUP BY bucket, server_instance_id");
for (int i = 0; i < groupByTags.size(); i++) s.append(", tag").append(i);
s.append("\n ) AS bucketed\n) AS deltas\n");
s.append("GROUP BY bucket");
for (int i = 0; i < groupByTags.size(); i++) s.append(", tag").append(i);
s.append("\nORDER BY bucket");
for (int i = 0; i < groupByTags.size(); i++) s.append(", tag").append(i);
return s.toString();
}
/**
* WHERE clause shared by both raw and delta SQL shapes. Appended at the
* correct indent under either the single {@code FROM server_metrics}
* (raw) or the innermost one (delta).
*/
private void appendWhereClause(StringBuilder s, Map<String, String> filterTags,
List<String> instanceAllowList,
String statistic, boolean isMean) {
s.append(" WHERE tenant_id = ?\n")
.append(" AND metric_name = ?\n");
if (isMean) {
s.append(" AND statistic IN ('count', 'total', 'total_time')\n");
} else if (statistic != null) {
s.append(" AND statistic = ?\n");
}
s.append(" AND collected_at >= ?\n")
.append(" AND collected_at < ?\n");
for (int i = 0; i < filterTags.size(); i++) {
s.append(" AND tags[?] = ?\n");
}
if (!instanceAllowList.isEmpty()) {
s.append(" AND server_instance_id IN (")
.append("?,".repeat(instanceAllowList.size() - 1)).append("?)\n");
}
}
/**
* SQL-positional params for both raw and delta queries (same relative
* order because the WHERE clause is emitted by {@link #appendWhereClause}
* only once, with the {@code tags[?]} select-list placeholders appearing
* earlier in the SQL text).
*/
private List<Object> buildParams(List<String> groupByTags, String metric,
String statistic, boolean isMean,
Instant from, Instant to,
Map<String, String> filterTags,
List<String> instanceAllowList) {
List<Object> params = new ArrayList<>();
// SELECT-list tags[?] placeholders
params.addAll(groupByTags);
// WHERE
params.add(tenantId);
params.add(metric);
if (!isMean && statistic != null) params.add(statistic);
params.add(Timestamp.from(from));
params.add(Timestamp.from(to));
for (Map.Entry<String, String> e : filterTags.entrySet()) {
params.add(e.getKey());
params.add(e.getValue());
}
params.addAll(instanceAllowList);
return params;
}
private static String scalarAggExpr(String aggregation) {
return switch (aggregation) {
case "avg" -> "avg(metric_value)";
case "sum" -> "sum(metric_value)";
case "max" -> "max(metric_value)";
case "min" -> "min(metric_value)";
case "latest" -> "argMax(metric_value, collected_at)";
default -> throw new IllegalStateException("unreachable: " + aggregation);
};
}
private static String meanExpr() {
return "sumIf(metric_value, statistic IN ('total', 'total_time'))"
+ " / nullIf(sumIf(metric_value, statistic = 'count'), 0)";
}
// ── response assembly ───────────────────────────────────────────────
private ServerMetricQueryResponse assembleSeries(
List<Row> rows, String metric, String statistic,
String aggregation, String mode, int step, List<String> groupByTags) {
Map<List<String>, List<ServerMetricPoint>> bySignature = new LinkedHashMap<>();
for (Row r : rows) {
if (Double.isNaN(r.value) || Double.isInfinite(r.value)) continue;
bySignature.computeIfAbsent(r.tagValues, k -> new ArrayList<>())
.add(new ServerMetricPoint(r.bucket, r.value));
}
if (bySignature.size() > MAX_SERIES) {
throw new IllegalArgumentException(
"query produced " + bySignature.size()
+ " series; reduce groupByTags or tighten filterTags (max "
+ MAX_SERIES + ")");
}
List<ServerMetricSeries> series = new ArrayList<>(bySignature.size());
for (Map.Entry<List<String>, List<ServerMetricPoint>> e : bySignature.entrySet()) {
Map<String, String> tags = new LinkedHashMap<>();
for (int i = 0; i < groupByTags.size(); i++) {
tags.put(groupByTags.get(i), e.getKey().get(i));
}
series.add(new ServerMetricSeries(Collections.unmodifiableMap(tags), e.getValue()));
}
return new ServerMetricQueryResponse(metric,
statistic != null ? statistic : "value",
aggregation, mode, step, series);
}
// ── helpers ─────────────────────────────────────────────────────────
private static void requireRange(Instant from, Instant to) {
if (from == null || to == null) {
throw new IllegalArgumentException("from and to are required");
}
if (!from.isBefore(to)) {
throw new IllegalArgumentException("from must be strictly before to");
}
if (Duration.between(from, to).compareTo(MAX_RANGE) > 0) {
throw new IllegalArgumentException(
"time range exceeds maximum of " + MAX_RANGE.toDays() + " days");
}
}
private static String requireSafeIdentifier(String value, String field) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " is required");
}
if (!SAFE_IDENTIFIER.matcher(value).matches()) {
throw new IllegalArgumentException(
field + " contains unsafe characters (allowed: [a-zA-Z0-9._])");
}
return value;
}
private static List<String> arrayToStringList(Array array) {
if (array == null) return List.of();
try {
Object[] values = (Object[]) array.getArray();
Set<String> sorted = new TreeSet<>();
for (Object v : values) {
if (v != null) sorted.add(v.toString());
}
return List.copyOf(sorted);
} catch (Exception e) {
return List.of();
} finally {
try { array.free(); } catch (Exception ignore) { }
}
}
private record Row(Instant bucket, List<String> tagValues, double value) {
}
}

View File

@@ -0,0 +1,46 @@
package com.cameleer.server.app.storage;
import com.cameleer.server.core.storage.ServerMetricsStore;
import com.cameleer.server.core.storage.model.ServerMetricSample;
import org.springframework.jdbc.core.JdbcTemplate;
import java.sql.Timestamp;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class ClickHouseServerMetricsStore implements ServerMetricsStore {
private final JdbcTemplate jdbc;
public ClickHouseServerMetricsStore(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Override
public void insertBatch(List<ServerMetricSample> samples) {
if (samples.isEmpty()) return;
jdbc.batchUpdate("""
INSERT INTO server_metrics
(tenant_id, collected_at, server_instance_id, metric_name,
metric_type, statistic, metric_value, tags)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
samples.stream().map(s -> new Object[]{
s.tenantId(),
Timestamp.from(s.collectedAt()),
s.serverInstanceId(),
s.metricName(),
s.metricType(),
s.statistic(),
s.value(),
tagsToClickHouseMap(s.tags())
}).toList());
}
private Map<String, String> tagsToClickHouseMap(Map<String, String> tags) {
if (tags == null || tags.isEmpty()) return new HashMap<>();
return new HashMap<>(tags);
}
}

View File

@@ -70,6 +70,12 @@ public class PostgresAppRepository implements AppRepository {
(rs, rowNum) -> mapRow(rs));
}
@Override
public long count() {
Long n = jdbc.queryForObject("SELECT COUNT(*) FROM apps", Long.class);
return n == null ? 0L : n;
}
@Override
public void updateContainerConfig(UUID id, Map<String, Object> containerConfig) {
try {

View File

@@ -1,6 +1,7 @@
package com.cameleer.server.app.storage;
import com.cameleer.server.core.runtime.Deployment;
import com.cameleer.server.core.runtime.DeploymentConfigSnapshot;
import com.cameleer.server.core.runtime.DeploymentRepository;
import com.cameleer.server.core.runtime.DeploymentStatus;
import com.fasterxml.jackson.core.type.TypeReference;
@@ -21,7 +22,7 @@ public class PostgresDeploymentRepository implements DeploymentRepository {
private static final String SELECT_COLS =
"id, app_id, app_version_id, environment_id, status, target_state, deployment_strategy, " +
"replica_states, deploy_stage, container_id, container_name, error_message, " +
"resolved_config, deployed_at, stopped_at, created_at";
"resolved_config, deployed_config_snapshot, deployed_at, stopped_at, created_at, created_by";
private final JdbcTemplate jdbc;
private final ObjectMapper objectMapper;
@@ -62,6 +63,16 @@ public class PostgresDeploymentRepository implements DeploymentRepository {
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
}
@Override
public Optional<Deployment> findActiveByAppIdAndEnvironmentIdExcluding(UUID appId, UUID environmentId, UUID excludeDeploymentId) {
var results = jdbc.query(
"SELECT " + SELECT_COLS + " FROM deployments WHERE app_id = ? AND environment_id = ? " +
"AND status IN ('STARTING', 'RUNNING', 'DEGRADED') AND id <> ? " +
"ORDER BY created_at DESC LIMIT 1",
(rs, rowNum) -> mapRow(rs), appId, environmentId, excludeDeploymentId);
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
}
public List<Deployment> findByStatus(List<DeploymentStatus> statuses) {
String placeholders = String.join(",", statuses.stream().map(s -> "'" + s.name() + "'").toList());
return jdbc.query(
@@ -70,10 +81,10 @@ public class PostgresDeploymentRepository implements DeploymentRepository {
}
@Override
public UUID create(UUID appId, UUID appVersionId, UUID environmentId, String containerName) {
public UUID create(UUID appId, UUID appVersionId, UUID environmentId, String containerName, String createdBy) {
UUID id = UUID.randomUUID();
jdbc.update("INSERT INTO deployments (id, app_id, app_version_id, environment_id, container_name) VALUES (?, ?, ?, ?, ?)",
id, appId, appVersionId, environmentId, containerName);
jdbc.update("INSERT INTO deployments (id, app_id, app_version_id, environment_id, container_name, created_by) VALUES (?, ?, ?, ?, ?, ?)",
id, appId, appVersionId, environmentId, containerName, createdBy);
return id;
}
@@ -115,8 +126,8 @@ public class PostgresDeploymentRepository implements DeploymentRepository {
}
@Override
public void deleteTerminalByAppAndEnvironment(UUID appId, UUID environmentId) {
jdbc.update("DELETE FROM deployments WHERE app_id = ? AND environment_id = ? AND status IN ('STOPPED', 'FAILED')",
public void deleteFailedByAppAndEnvironment(UUID appId, UUID environmentId) {
jdbc.update("DELETE FROM deployments WHERE app_id = ? AND environment_id = ? AND status = 'FAILED'",
appId, environmentId);
}
@@ -129,6 +140,27 @@ public class PostgresDeploymentRepository implements DeploymentRepository {
}
}
public void saveDeployedConfigSnapshot(UUID id, DeploymentConfigSnapshot snapshot) {
try {
String json = snapshot != null ? objectMapper.writeValueAsString(snapshot) : null;
jdbc.update("UPDATE deployments SET deployed_config_snapshot = ?::jsonb WHERE id = ?", json, id);
} catch (Exception e) {
throw new RuntimeException("Failed to serialize deployed_config_snapshot", e);
}
}
public Optional<Deployment> findLatestSuccessfulByAppAndEnv(UUID appId, UUID envId) {
// DEGRADED deploys also carry a snapshot (executor writes before the RUNNING/DEGRADED
// split), and represent a config that reached COMPLETE stage — restorable for the user.
var results = jdbc.query(
"SELECT " + SELECT_COLS + " FROM deployments "
+ "WHERE app_id = ? AND environment_id = ? "
+ "AND status IN ('RUNNING', 'DEGRADED') AND deployed_config_snapshot IS NOT NULL "
+ "ORDER BY deployed_at DESC NULLS LAST LIMIT 1",
(rs, rowNum) -> mapRow(rs), appId, envId);
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
}
public Optional<Deployment> findByContainerId(String containerId) {
var results = jdbc.query(
"SELECT " + SELECT_COLS + " FROM deployments WHERE replica_states::text LIKE ? " +
@@ -158,6 +190,15 @@ public class PostgresDeploymentRepository implements DeploymentRepository {
throw new SQLException("Failed to deserialize resolved_config", e);
}
}
DeploymentConfigSnapshot deployedConfigSnapshot = null;
String snapshotJson = rs.getString("deployed_config_snapshot");
if (snapshotJson != null) {
try {
deployedConfigSnapshot = objectMapper.readValue(snapshotJson, DeploymentConfigSnapshot.class);
} catch (Exception e) {
throw new SQLException("Failed to deserialize deployed_config_snapshot", e);
}
}
return new Deployment(
UUID.fromString(rs.getString("id")),
UUID.fromString(rs.getString("app_id")),
@@ -172,9 +213,11 @@ public class PostgresDeploymentRepository implements DeploymentRepository {
rs.getString("container_name"),
rs.getString("error_message"),
resolvedConfig,
deployedConfigSnapshot,
deployedAt != null ? deployedAt.toInstant() : null,
stoppedAt != null ? stoppedAt.toInstant() : null,
rs.getTimestamp("created_at").toInstant()
rs.getTimestamp("created_at").toInstant(),
rs.getString("created_by")
);
}
}

View File

@@ -26,7 +26,8 @@ public class PostgresEnvironmentRepository implements EnvironmentRepository {
}
private static final String SELECT_COLS =
"id, slug, display_name, production, enabled, default_container_config, jar_retention_count, color, created_at";
"id, slug, display_name, production, enabled, default_container_config, jar_retention_count, color, created_at, "
+ "execution_retention_days, log_retention_days, metric_retention_days";
@Override
public List<Environment> findAll() {
@@ -35,6 +36,11 @@ public class PostgresEnvironmentRepository implements EnvironmentRepository {
(rs, rowNum) -> mapRow(rs));
}
@Override
public long count() {
return jdbc.queryForObject("SELECT COUNT(*) FROM environments", Long.class);
}
@Override
public Optional<Environment> findById(UUID id) {
var results = jdbc.query(
@@ -108,7 +114,10 @@ public class PostgresEnvironmentRepository implements EnvironmentRepository {
config,
jarRetentionCount,
color,
rs.getTimestamp("created_at").toInstant()
rs.getTimestamp("created_at").toInstant(),
rs.getInt("execution_retention_days"),
rs.getInt("log_retention_days"),
rs.getInt("metric_retention_days")
);
}
}

View File

@@ -101,6 +101,12 @@ public class PostgresUserRepository implements UserRepository {
java.sql.Timestamp.from(timestamp), userId);
}
@Override
public long count() {
Long n = jdbc.queryForObject("SELECT COUNT(*) FROM users", Long.class);
return n == null ? 0L : n;
}
private UserInfo mapUser(java.sql.ResultSet rs) throws java.sql.SQLException {
java.sql.Timestamp ts = rs.getTimestamp("created_at");
java.time.Instant createdAt = ts != null ? ts.toInstant() : null;

View File

@@ -47,6 +47,11 @@ cameleer:
jarstoragepath: ${CAMELEER_SERVER_RUNTIME_JARSTORAGEPATH:/data/jars}
baseimage: ${CAMELEER_SERVER_RUNTIME_BASEIMAGE:gitea.siegeln.net/cameleer/cameleer-runtime-base:latest}
dockernetwork: ${CAMELEER_SERVER_RUNTIME_DOCKERNETWORK:cameleer}
# Container runtime override. Empty (default) auto-detects: uses runsc
# (gVisor) if the daemon has it registered, otherwise the daemon default
# (runc). Set to a registered runtime name (e.g. "kata", "runc") to
# force a specific runtime. See issue #152 for the threat model.
dockerruntime: ${CAMELEER_SERVER_RUNTIME_DOCKERRUNTIME:}
agenthealthport: 9464
healthchecktimeout: 60
container:
@@ -55,6 +60,7 @@ cameleer:
routingmode: ${CAMELEER_SERVER_RUNTIME_ROUTINGMODE:path}
routingdomain: ${CAMELEER_SERVER_RUNTIME_ROUTINGDOMAIN:localhost}
serverurl: ${CAMELEER_SERVER_RUNTIME_SERVERURL:}
certresolver: ${CAMELEER_SERVER_RUNTIME_CERTRESOLVER:}
jardockervolume: ${CAMELEER_SERVER_RUNTIME_JARDOCKERVOLUME:}
indexer:
debouncems: ${CAMELEER_SERVER_INDEXER_DEBOUNCEMS:2000}
@@ -111,6 +117,10 @@ cameleer:
url: ${CAMELEER_SERVER_CLICKHOUSE_URL:jdbc:clickhouse://localhost:8123/cameleer}
username: ${CAMELEER_SERVER_CLICKHOUSE_USERNAME:default}
password: ${CAMELEER_SERVER_CLICKHOUSE_PASSWORD:}
self-metrics:
enabled: ${CAMELEER_SERVER_SELFMETRICS_ENABLED:true}
interval-ms: ${CAMELEER_SERVER_SELFMETRICS_INTERVALMS:60000}
instance-id: ${CAMELEER_SERVER_INSTANCE_ID:}
springdoc:
api-docs:

View File

@@ -401,6 +401,29 @@ CREATE TABLE IF NOT EXISTS route_catalog (
ENGINE = ReplacingMergeTree(last_seen)
ORDER BY (tenant_id, environment, application_id, route_id);
-- ── Server Self-Metrics ────────────────────────────────────────────────
-- Periodic snapshot of the server's own Micrometer registry (written by
-- ServerMetricsSnapshotScheduler). No `environment` column — the server
-- straddles environments. `statistic` distinguishes Timer/DistributionSummary
-- sub-measurements (count, total_time, max, mean) from plain counter/gauge values.
CREATE TABLE IF NOT EXISTS server_metrics (
tenant_id LowCardinality(String) DEFAULT 'default',
collected_at DateTime64(3),
server_instance_id LowCardinality(String),
metric_name LowCardinality(String),
metric_type LowCardinality(String),
statistic LowCardinality(String) DEFAULT 'value',
metric_value Float64,
tags Map(String, String) DEFAULT map(),
server_received_at DateTime64(3) DEFAULT now64(3)
)
ENGINE = MergeTree()
PARTITION BY (tenant_id, toYYYYMM(collected_at))
ORDER BY (tenant_id, collected_at, server_instance_id, metric_name, statistic)
TTL toDateTime(collected_at) + INTERVAL 90 DAY DELETE
SETTINGS index_granularity = 8192;
-- insert_id tiebreak for keyset pagination (fixes same-millisecond cursor collision).
-- IF NOT EXISTS on ADD COLUMN is idempotent. MATERIALIZE COLUMN is a background mutation,
-- effectively a no-op once all parts are already materialized.

View File

@@ -0,0 +1,7 @@
-- V3: per-deployment config snapshot for "last known good" + dirty detection
-- Captures {jarVersionId, agentConfig, containerConfig} at the moment a
-- deployment transitions to RUNNING. Historical rows are NULL; dirty detection
-- treats NULL as "everything dirty" and the next successful Redeploy populates it.
ALTER TABLE deployments
ADD COLUMN deployed_config_snapshot JSONB;

View File

@@ -0,0 +1,8 @@
-- V4: add created_by column to deployments for audit trail
-- Captures which user initiated a deployment. Nullable for backwards compatibility;
-- pre-V4 historical deployments will have NULL.
ALTER TABLE deployments
ADD COLUMN created_by TEXT REFERENCES users(user_id);
CREATE INDEX idx_deployments_created_by ON deployments (created_by);

View File

@@ -0,0 +1,17 @@
-- Per-tenant license row (one server = one tenant)
CREATE TABLE license (
tenant_id TEXT PRIMARY KEY,
token TEXT NOT NULL,
license_id UUID NOT NULL,
installed_at TIMESTAMPTZ NOT NULL,
installed_by TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
last_validated_at TIMESTAMPTZ NOT NULL
);
-- Per-env retention; defaults to default-tier values (1 day) so a fresh
-- server lands inside the cap without operator intervention.
ALTER TABLE environments
ADD COLUMN execution_retention_days INTEGER NOT NULL DEFAULT 1,
ADD COLUMN log_retention_days INTEGER NOT NULL DEFAULT 1,
ADD COLUMN metric_retention_days INTEGER NOT NULL DEFAULT 1;

View File

@@ -21,10 +21,12 @@ public abstract class AbstractPostgresIT {
postgres = new PostgreSQLContainer<>("postgres:16")
.withDatabaseName("cameleer")
.withUsername("cameleer")
.withPassword("test");
.withPassword("test")
.withReuse(true);
postgres.start();
clickhouse = new ClickHouseContainer("clickhouse/clickhouse-server:24.12");
clickhouse = new ClickHouseContainer("clickhouse/clickhouse-server:24.12")
.withReuse(true);
clickhouse.start();
}

View File

@@ -1,13 +1,19 @@
package com.cameleer.server.app;
import com.cameleer.server.core.agent.AgentRegistryService;
import com.cameleer.server.core.license.LicenseGate;
import com.cameleer.server.core.license.LicenseInfo;
import com.cameleer.server.core.security.JwtService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Test utility for creating JWT-authenticated requests in integration tests.
@@ -20,10 +26,39 @@ public class TestSecurityHelper {
private final JwtService jwtService;
private final AgentRegistryService agentRegistryService;
private final LicenseGate licenseGate;
public TestSecurityHelper(JwtService jwtService, AgentRegistryService agentRegistryService) {
@Autowired
public TestSecurityHelper(JwtService jwtService,
AgentRegistryService agentRegistryService,
LicenseGate licenseGate) {
this.jwtService = jwtService;
this.agentRegistryService = agentRegistryService;
this.licenseGate = licenseGate;
}
/**
* Loads a synthetic, signature-bypassing license into {@link LicenseGate} so the test can
* exercise paths that would otherwise be rejected by default-tier caps. The license is
* always-ACTIVE (1 day from now, no grace) and limits are merged over defaults — only
* supply the keys you want to lift. Use this from {@code @BeforeEach} in ITs that need to
* create more than the default-tier allowance of envs/apps/users/etc.
*/
public void installSyntheticUnsignedLicense(Map<String, Integer> caps) {
LicenseInfo info = new LicenseInfo(
UUID.randomUUID(),
"default",
"test-license",
Map.copyOf(caps),
Instant.now(),
Instant.now().plus(1, ChronoUnit.DAYS),
0);
licenseGate.load(info);
}
/** Clears any test license previously installed via {@link #installSyntheticUnsignedLicense}. */
public void clearTestLicense() {
licenseGate.clear();
}
/**

View File

@@ -105,6 +105,11 @@ class AlertingFullLifecycleIT extends AbstractPostgresIT {
.dynamicHttpsPort());
wm.start();
// Lift the default-tier max_alert_rules cap (=2). This lifecycle test creates
// multiple rules via REST + repo across @Test methods (PER_CLASS lifecycle) and
// is not exercising the license cap. Synthetic license is ACTIVE-state.
securityHelper.installSyntheticUnsignedLicense(java.util.Map.of("max_alert_rules", 100));
// Default clock behaviour: delegate to simulatedNow
stubClock();
@@ -145,6 +150,7 @@ class AlertingFullLifecycleIT extends AbstractPostgresIT {
@AfterAll
void cleanupFixtures() {
securityHelper.clearTestLicense();
if (wm != null) wm.stop();
jdbcTemplate.update("DELETE FROM alert_silences WHERE environment_id = ?", envId);
jdbcTemplate.update("DELETE FROM alert_notifications WHERE alert_instance_id IN (SELECT id FROM alert_instances WHERE environment_id = ?)", envId);

View File

@@ -56,6 +56,13 @@ class OutboundConnectionAllowedEnvIT extends AbstractPostgresIT {
void setUp() throws Exception {
when(agentRegistryService.findAll()).thenReturn(List.of());
// Lift caps so this connection-allowed-env test, which creates one alert rule per
// method, is never gated by the default-tier max_alert_rules=2 + sibling residue.
// Also lift max_outbound_connections (default=1) — every test creates one connection.
securityHelper.installSyntheticUnsignedLicense(java.util.Map.of(
"max_alert_rules", 100,
"max_outbound_connections", 100));
adminJwt = securityHelper.adminToken();
operatorJwt = securityHelper.operatorToken();
@@ -93,6 +100,7 @@ class OutboundConnectionAllowedEnvIT extends AbstractPostgresIT {
@AfterEach
void cleanUp() {
securityHelper.clearTestLicense();
jdbcTemplate.update("DELETE FROM alert_rules WHERE environment_id IN (?, ?, ?)", envIdA, envIdB, envIdC);
jdbcTemplate.update("DELETE FROM outbound_connections WHERE id = ?", connId);
jdbcTemplate.update("DELETE FROM environments WHERE id IN (?, ?, ?)", envIdA, envIdB, envIdC);

View File

@@ -44,6 +44,11 @@ class AlertRuleControllerIT extends AbstractPostgresIT {
seedUser("test-operator");
seedUser("test-viewer");
// Lift the default-tier max_alert_rules cap (=2) so this suite — which exercises rule
// creation independent of the cap — is not gated by sibling-test residue in the
// shared Spring context's Postgres tables. The synthetic license is ACTIVE-state.
securityHelper.installSyntheticUnsignedLicense(java.util.Map.of("max_alert_rules", 100));
// Create a test environment
envSlug = "test-env-" + UUID.randomUUID().toString().substring(0, 8);
envId = UUID.randomUUID();
@@ -54,6 +59,7 @@ class AlertRuleControllerIT extends AbstractPostgresIT {
@AfterEach
void cleanUp() {
securityHelper.clearTestLicense();
jdbcTemplate.update("DELETE FROM alert_rules WHERE environment_id = ?", envId);
jdbcTemplate.update("DELETE FROM environments WHERE id = ?", envId);
jdbcTemplate.update("DELETE FROM users WHERE user_id IN ('test-operator','test-viewer')");

View File

@@ -37,7 +37,7 @@ class AgentLifecycleEvaluatorTest {
events = mock(AgentEventRepository.class);
envRepo = mock(EnvironmentRepository.class);
when(envRepo.findById(ENV_ID)).thenReturn(Optional.of(
new Environment(ENV_ID, ENV_SLUG, "Prod", true, true, Map.of(), 5, "slate", Instant.EPOCH)));
new Environment(ENV_ID, ENV_SLUG, "Prod", true, true, Map.of(), 5, "slate", Instant.EPOCH, 1, 1, 1)));
eval = new AgentLifecycleEvaluator(events, envRepo);
}

View File

@@ -48,7 +48,7 @@ class DeploymentStateEvaluatorTest {
private Deployment deployment(DeploymentStatus status) {
return new Deployment(DEP_ID, APP_ID, UUID.randomUUID(), ENV_ID, status,
null, null, List.of(), null, null, "orders-0", null,
Map.of(), NOW.minusSeconds(60), null, NOW.minusSeconds(120));
Map.of(), null, NOW.minusSeconds(60), null, NOW.minusSeconds(120), "test-user");
}
@Test

View File

@@ -41,7 +41,7 @@ class ExchangeMatchEvaluatorTest {
null, null, null, null, null, null, null, null, null, null, null, null, null, null);
eval = new ExchangeMatchEvaluator(searchIndex, envRepo, props);
var env = new Environment(ENV_ID, "prod", "Production", false, true, null, null, "slate", null);
var env = new Environment(ENV_ID, "prod", "Production", false, true, null, null, "slate", null, 1, 1, 1);
when(envRepo.findById(ENV_ID)).thenReturn(Optional.of(env));
}

View File

@@ -35,7 +35,7 @@ class LogPatternEvaluatorTest {
envRepo = mock(EnvironmentRepository.class);
eval = new LogPatternEvaluator(logStore, envRepo);
var env = new Environment(ENV_ID, "prod", "Production", false, true, null, null, "slate", null);
var env = new Environment(ENV_ID, "prod", "Production", false, true, null, null, "slate", null, 1, 1, 1);
when(envRepo.findById(ENV_ID)).thenReturn(Optional.of(env));
}

View File

@@ -36,7 +36,7 @@ class RouteMetricEvaluatorTest {
envRepo = mock(EnvironmentRepository.class);
eval = new RouteMetricEvaluator(statsStore, envRepo);
var env = new Environment(ENV_ID, "prod", "Production", false, true, null, null, "slate", null);
var env = new Environment(ENV_ID, "prod", "Production", false, true, null, null, "slate", null, 1, 1, 1);
when(envRepo.findById(ENV_ID)).thenReturn(Optional.of(env));
}

View File

@@ -28,7 +28,7 @@ class NotificationContextBuilderTest {
// ---- helpers ----
private Environment env() {
return new Environment(ENV_ID, "prod", "Production", true, true, Map.of(), 5, "slate", Instant.EPOCH);
return new Environment(ENV_ID, "prod", "Production", true, true, Map.of(), 5, "slate", Instant.EPOCH, 1, 1, 1);
}
private AlertRule rule(ConditionKind kind) {

View File

@@ -52,10 +52,14 @@ class SchemaBootstrapIT extends AbstractPostgresIT {
@Test
void alerting_enums_exist() {
// Scope to current schema's namespace — Testcontainers reuse can otherwise
// expose enums from a previous run's tenant_default schema alongside public.
var enums = jdbcTemplate.queryForList("""
SELECT typname FROM pg_type
WHERE typname IN ('severity_enum','condition_kind_enum','alert_state_enum',
'target_kind_enum','notification_status_enum')
SELECT t.typname FROM pg_type t
JOIN pg_namespace n ON n.oid = t.typnamespace
WHERE t.typname IN ('severity_enum','condition_kind_enum','alert_state_enum',
'target_kind_enum','notification_status_enum')
AND n.nspname = current_schema()
""", String.class);
assertThat(enums).containsExactlyInAnyOrder(
"severity_enum", "condition_kind_enum", "alert_state_enum",
@@ -86,6 +90,7 @@ class SchemaBootstrapIT extends AbstractPostgresIT {
SELECT column_name FROM information_schema.columns
WHERE table_name = 'alert_instances'
AND column_name IN ('read_at','deleted_at')
AND table_schema = current_schema()
""", String.class);
assertThat(cols).containsExactlyInAnyOrder("read_at", "deleted_at");
}
@@ -96,17 +101,105 @@ class SchemaBootstrapIT extends AbstractPostgresIT {
SELECT COUNT(*)::int FROM pg_indexes
WHERE indexname = 'alert_instances_open_rule_uq'
AND tablename = 'alert_instances'
AND schemaname = current_schema()
""", Integer.class);
assertThat(count).isEqualTo(1);
Boolean isUnique = jdbcTemplate.queryForObject("""
SELECT indisunique FROM pg_index
JOIN pg_class ON pg_class.oid = pg_index.indexrelid
WHERE pg_class.relname = 'alert_instances_open_rule_uq'
JOIN pg_class c ON c.oid = pg_index.indexrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = 'alert_instances_open_rule_uq'
AND n.nspname = current_schema()
""", Boolean.class);
assertThat(isUnique).isTrue();
}
@Test
void licenseTableExists() {
// V5 migration: per-tenant license row, PK on tenant_id (one server = one tenant).
var rows = jdbcTemplate.queryForList("""
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'license'
AND table_schema = current_schema()
""");
var byName = new java.util.HashMap<String, java.util.Map<String, Object>>();
for (var row : rows) {
byName.put((String) row.get("column_name"), row);
}
assertThat(byName).containsKeys(
"tenant_id", "license_id", "token", "installed_at",
"installed_by", "expires_at", "last_validated_at");
assertThat(byName.get("tenant_id").get("data_type")).isEqualTo("text");
assertThat(byName.get("tenant_id").get("is_nullable")).isEqualTo("NO");
assertThat(byName.get("license_id").get("data_type")).isEqualTo("uuid");
assertThat(byName.get("license_id").get("is_nullable")).isEqualTo("NO");
assertThat(byName.get("token").get("data_type")).isEqualTo("text");
assertThat(byName.get("token").get("is_nullable")).isEqualTo("NO");
assertThat(byName.get("installed_at").get("data_type"))
.isEqualTo("timestamp with time zone");
assertThat(byName.get("installed_at").get("is_nullable")).isEqualTo("NO");
assertThat(byName.get("installed_by").get("data_type")).isEqualTo("text");
assertThat(byName.get("installed_by").get("is_nullable")).isEqualTo("NO");
assertThat(byName.get("expires_at").get("data_type"))
.isEqualTo("timestamp with time zone");
assertThat(byName.get("expires_at").get("is_nullable")).isEqualTo("NO");
assertThat(byName.get("last_validated_at").get("data_type"))
.isEqualTo("timestamp with time zone");
assertThat(byName.get("last_validated_at").get("is_nullable")).isEqualTo("NO");
// PK: tenant_id (one row per tenant).
var pkCols = jdbcTemplate.queryForList("""
SELECT a.attname AS column_name
FROM pg_index i
JOIN pg_class c ON c.oid = i.indrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = ANY(i.indkey)
WHERE c.relname = 'license'
AND n.nspname = current_schema()
AND i.indisprimary
""", String.class);
assertThat(pkCols).containsExactly("tenant_id");
}
@Test
void environmentsHasRetentionColumns() {
// V5 migration adds three retention day columns, NOT NULL DEFAULT 1.
var rows = jdbcTemplate.queryForList("""
SELECT column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_name = 'environments'
AND table_schema = current_schema()
AND column_name IN
('execution_retention_days','log_retention_days','metric_retention_days')
""");
var byName = new java.util.HashMap<String, java.util.Map<String, Object>>();
for (var row : rows) {
byName.put((String) row.get("column_name"), row);
}
assertThat(byName).containsKeys(
"execution_retention_days", "log_retention_days", "metric_retention_days");
for (var col : java.util.List.of(
"execution_retention_days", "log_retention_days", "metric_retention_days")) {
assertThat(byName.get(col).get("data_type"))
.as("%s data_type", col).isEqualTo("integer");
assertThat(byName.get(col).get("is_nullable"))
.as("%s is_nullable", col).isEqualTo("NO");
assertThat((String) byName.get(col).get("column_default"))
.as("%s column_default", col).isEqualTo("1");
}
}
@Test
void deleting_environment_cascades_alerting_rows() {
testEnvId = UUID.randomUUID();

View File

@@ -4,6 +4,7 @@ import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -13,6 +14,7 @@ import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@@ -33,10 +35,18 @@ class AgentCommandControllerIT extends AbstractPostgresIT {
@BeforeEach
void setUp() {
// Lift max_agents cap so this IT (which registers many agents per test) isn't gated
// by license enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT.
securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100));
agentJwt = securityHelper.registerTestAgent("test-agent-command-it");
operatorJwt = securityHelper.operatorToken();
}
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
private ResponseEntity<String> registerAgent(String agentId, String name, String application) {
String json = """
{

View File

@@ -4,6 +4,7 @@ import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -13,6 +14,8 @@ import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class AgentRegistrationControllerIT extends AbstractPostgresIT {
@@ -31,10 +34,18 @@ class AgentRegistrationControllerIT extends AbstractPostgresIT {
@BeforeEach
void setUp() {
// Lift max_agents cap so this IT (which registers many agents per test) isn't gated
// by license enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT.
securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100));
jwt = securityHelper.registerTestAgent("test-agent-registration-it");
viewerJwt = securityHelper.viewerToken();
}
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
private ResponseEntity<String> registerAgent(String agentId, String name) {
String json = """
{

View File

@@ -3,6 +3,7 @@ package com.cameleer.server.app.controller;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -20,6 +21,7 @@ import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
@@ -48,10 +50,18 @@ class AgentSseControllerIT extends AbstractPostgresIT {
@BeforeEach
void setUp() {
// Lift max_agents cap so this IT (which registers many agents per test) isn't gated
// by license enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT.
securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100));
jwt = securityHelper.registerTestAgent("test-agent-sse-it");
operatorJwt = securityHelper.operatorToken();
}
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
private ResponseEntity<String> registerAgent(String agentId, String name, String application) {
String json = """
{

View File

@@ -0,0 +1,239 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.cameleer.server.app.dto.DirtyStateResponse;
import com.cameleer.server.app.storage.PostgresDeploymentRepository;
import com.cameleer.server.core.runtime.ContainerStatus;
import com.cameleer.server.core.runtime.Deployment;
import com.cameleer.server.core.runtime.DeploymentStatus;
import com.cameleer.server.core.runtime.RuntimeOrchestrator;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
/**
* Integration tests for GET /api/v1/environments/{envSlug}/apps/{appSlug}/dirty-state.
*
* <p>Uses @MockBean RuntimeOrchestrator (same pattern as DeploymentSnapshotIT).
* @DirtiesContext prevents context cache conflicts when both IT classes are loaded together.</p>
*/
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
class AppDirtyStateIT extends AbstractPostgresIT {
@MockBean
RuntimeOrchestrator runtimeOrchestrator;
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private TestSecurityHelper securityHelper;
@Autowired
private PostgresDeploymentRepository deploymentRepository;
private String operatorJwt;
@BeforeEach
void setUp() {
operatorJwt = securityHelper.operatorToken();
jdbcTemplate.update("DELETE FROM deployments");
jdbcTemplate.update("DELETE FROM app_versions");
jdbcTemplate.update("DELETE FROM apps");
jdbcTemplate.update("DELETE FROM application_config WHERE environment = 'default'");
// Ensure test-operator exists in users table (required for deployments.created_by FK)
jdbcTemplate.update(
"INSERT INTO users (user_id, provider, display_name) VALUES ('test-operator', 'local', 'Test Operator') ON CONFLICT (user_id) DO NOTHING");
}
// -----------------------------------------------------------------------
// Test 1: no deployment ever → dirty=true, lastSuccessfulDeploymentId=null
// -----------------------------------------------------------------------
@Test
void dirtyState_noDeployEver_returnsDirtyTrue() throws Exception {
String appSlug = "ds-nodeploy-" + UUID.randomUUID().toString().substring(0, 8);
post("/api/v1/environments/default/apps",
String.format("{\"slug\": \"%s\", \"displayName\": \"DS No Deploy\"}", appSlug),
operatorJwt);
uploadJar(appSlug, ("fake-jar-" + appSlug).getBytes());
put("/api/v1/environments/default/apps/" + appSlug + "/config",
"{\"samplingRate\": 0.5}", operatorJwt);
DirtyStateResponse body = getDirtyState("default", appSlug);
assertThat(body.dirty()).isTrue();
assertThat(body.lastSuccessfulDeploymentId()).isNull();
}
// -----------------------------------------------------------------------
// Test 2: after a successful deploy with matching desired state → dirty=false
// -----------------------------------------------------------------------
@Test
void dirtyState_afterSuccessfulDeploy_matchingDesiredState_returnsDirtyFalse() throws Exception {
String fakeContainerId = "fake-cid-" + UUID.randomUUID();
when(runtimeOrchestrator.isEnabled()).thenReturn(true);
when(runtimeOrchestrator.startContainer(any())).thenReturn(fakeContainerId);
when(runtimeOrchestrator.getContainerStatus(fakeContainerId))
.thenReturn(new ContainerStatus("healthy", true, 0, null));
String appSlug = "ds-clean-" + UUID.randomUUID().toString().substring(0, 8);
post("/api/v1/environments/default/apps",
String.format("{\"slug\": \"%s\", \"displayName\": \"DS Clean\"}", appSlug),
operatorJwt);
put("/api/v1/environments/default/apps/" + appSlug + "/container-config",
"{\"runtimeType\": \"spring-boot\", \"appPort\": 8081}", operatorJwt);
String versionId = uploadJar(appSlug, ("fake-jar-clean-" + appSlug).getBytes());
put("/api/v1/environments/default/apps/" + appSlug + "/config",
"{\"samplingRate\": 0.25}", operatorJwt);
// Deploy and wait for RUNNING
JsonNode deploy = post(
"/api/v1/environments/default/apps/" + appSlug + "/deployments",
String.format("{\"appVersionId\": \"%s\"}", versionId),
operatorJwt);
String deploymentId = deploy.path("id").asText();
await().atMost(30, TimeUnit.SECONDS).pollInterval(500, TimeUnit.MILLISECONDS)
.untilAsserted(() -> {
Deployment d = deploymentRepository.findById(UUID.fromString(deploymentId))
.orElseThrow(() -> new AssertionError("Deployment not found"));
assertThat(d.status()).isEqualTo(DeploymentStatus.RUNNING);
});
// Desired state matches what was deployed → dirty=false
DirtyStateResponse body = getDirtyState("default", appSlug);
assertThat(body.dirty()).isFalse();
assertThat(body.differences()).isEmpty();
assertThat(body.lastSuccessfulDeploymentId()).isEqualTo(deploymentId);
}
// -----------------------------------------------------------------------
// Test 3: after successful deploy, config changed → dirty=true
// -----------------------------------------------------------------------
@Test
void dirtyState_afterSuccessfulDeploy_configChanged_returnsDirtyTrue() throws Exception {
String fakeContainerId = "fake-cid2-" + UUID.randomUUID();
when(runtimeOrchestrator.isEnabled()).thenReturn(true);
when(runtimeOrchestrator.startContainer(any())).thenReturn(fakeContainerId);
when(runtimeOrchestrator.getContainerStatus(fakeContainerId))
.thenReturn(new ContainerStatus("healthy", true, 0, null));
String appSlug = "ds-dirty-" + UUID.randomUUID().toString().substring(0, 8);
post("/api/v1/environments/default/apps",
String.format("{\"slug\": \"%s\", \"displayName\": \"DS Dirty\"}", appSlug),
operatorJwt);
put("/api/v1/environments/default/apps/" + appSlug + "/container-config",
"{\"runtimeType\": \"spring-boot\", \"appPort\": 8081}", operatorJwt);
String versionId = uploadJar(appSlug, ("fake-jar-dirty-" + appSlug).getBytes());
put("/api/v1/environments/default/apps/" + appSlug + "/config",
"{\"samplingRate\": 0.1}", operatorJwt);
// Deploy and wait for RUNNING
JsonNode deploy = post(
"/api/v1/environments/default/apps/" + appSlug + "/deployments",
String.format("{\"appVersionId\": \"%s\"}", versionId),
operatorJwt);
String deploymentId = deploy.path("id").asText();
await().atMost(30, TimeUnit.SECONDS).pollInterval(500, TimeUnit.MILLISECONDS)
.untilAsserted(() -> {
Deployment d = deploymentRepository.findById(UUID.fromString(deploymentId))
.orElseThrow(() -> new AssertionError("Deployment not found"));
assertThat(d.status()).isEqualTo(DeploymentStatus.RUNNING);
});
// Change samplingRate after deploy
put("/api/v1/environments/default/apps/" + appSlug + "/config",
"{\"samplingRate\": 0.9}", operatorJwt);
// Now desired state differs from snapshot → dirty=true
DirtyStateResponse body = getDirtyState("default", appSlug);
assertThat(body.dirty()).isTrue();
assertThat(body.lastSuccessfulDeploymentId()).isEqualTo(deploymentId);
assertThat(body.differences()).isNotEmpty();
assertThat(body.differences())
.anyMatch(d -> d.field().contains("samplingRate"));
}
// -----------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------
private DirtyStateResponse getDirtyState(String envSlug, String appSlug) {
HttpHeaders headers = securityHelper.authHeaders(operatorJwt);
var response = restTemplate.exchange(
"/api/v1/environments/" + envSlug + "/apps/" + appSlug + "/dirty-state",
HttpMethod.GET,
new HttpEntity<>(headers),
DirtyStateResponse.class);
assertThat(response.getStatusCode().value()).isEqualTo(200);
return response.getBody();
}
private JsonNode post(String path, String json, String jwt) throws Exception {
HttpHeaders headers = securityHelper.authHeaders(jwt);
var response = restTemplate.exchange(
path, HttpMethod.POST,
new HttpEntity<>(json, headers),
String.class);
return objectMapper.readTree(response.getBody());
}
private void put(String path, String json, String jwt) {
HttpHeaders headers = securityHelper.authHeaders(jwt);
restTemplate.exchange(path, HttpMethod.PUT, new HttpEntity<>(json, headers), String.class);
}
private String uploadJar(String appSlug, byte[] content) throws Exception {
ByteArrayResource resource = new ByteArrayResource(content) {
@Override
public String getFilename() { return "app.jar"; }
};
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("file", resource);
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + operatorJwt);
headers.set("X-Cameleer-Protocol-Version", "1");
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
var response = restTemplate.exchange(
"/api/v1/environments/default/apps/" + appSlug + "/versions",
HttpMethod.POST,
new HttpEntity<>(body, headers),
String.class);
JsonNode versionNode = objectMapper.readTree(response.getBody());
return versionNode.path("id").asText();
}
}

View File

@@ -0,0 +1,200 @@
package com.cameleer.server.app.controller;
import com.cameleer.common.model.ApplicationConfig;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.cameleer.server.app.storage.PostgresApplicationConfigRepository;
import com.cameleer.server.core.agent.AgentRegistryService;
import com.cameleer.server.core.agent.CommandType;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.mock.mockito.SpyBean;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.annotation.DirtiesContext.ClassMode;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@DirtiesContext(classMode = ClassMode.AFTER_CLASS)
class ApplicationConfigControllerIT extends AbstractPostgresIT {
/**
* Spy on the real AgentRegistryService bean so we can verify whether
* addGroupCommandWithReplies was invoked (live) or skipped (staged).
*/
@SpyBean
AgentRegistryService registryService;
@Autowired private TestRestTemplate restTemplate;
@Autowired private TestSecurityHelper securityHelper;
@Autowired private PostgresApplicationConfigRepository configRepository;
private String operatorJwt;
/** Unique env slug per test to avoid cross-test pollution. */
private String envSlug;
private UUID envId;
/** Unique app slug per test run to avoid cross-test row collisions. */
private String appSlug;
@BeforeEach
void setUp() {
operatorJwt = securityHelper.operatorToken();
envSlug = "cfg-it-" + UUID.randomUUID().toString().substring(0, 8);
envId = UUID.randomUUID();
appSlug = "paygw-" + UUID.randomUUID().toString().substring(0, 8);
jdbcTemplate.update(
"INSERT INTO environments (id, slug, display_name) VALUES (?, ?, ?) ON CONFLICT (id) DO NOTHING",
envId, envSlug, envSlug);
}
@AfterEach
void cleanUp() {
jdbcTemplate.update("DELETE FROM application_config WHERE environment = ?", envSlug);
jdbcTemplate.update("DELETE FROM environments WHERE id = ?", envId);
}
// ── helpers ──────────────────────────────────────────────────────────────
private void registerLiveAgent(String agentId) {
// Use the bootstrap HTTP endpoint — same pattern as AgentCommandControllerIT.
String body = """
{
"instanceId": "%s",
"applicationId": "%s",
"environmentId": "%s",
"version": "1.0.0",
"routeIds": ["route-1"],
"capabilities": {}
}
""".formatted(agentId, appSlug, envSlug);
restTemplate.postForEntity(
"/api/v1/agents/register",
new HttpEntity<>(body, securityHelper.bootstrapHeaders()),
String.class);
}
private ResponseEntity<String> putConfig(String apply) {
String url = "/api/v1/environments/" + envSlug + "/apps/" + appSlug + "/config"
+ (apply != null ? "?apply=" + apply : "");
String body = """
{"samplingRate": 0.1, "metricsEnabled": true}
""";
return restTemplate.exchange(url, HttpMethod.PUT,
new HttpEntity<>(body, securityHelper.authHeaders(operatorJwt)), String.class);
}
// ── tests ─────────────────────────────────────────────────────────────────
@Test
void putConfig_staged_savesButDoesNotPush() {
// Given — one LIVE agent registered for (appSlug, envSlug)
String agentId = "staged-agent-" + UUID.randomUUID().toString().substring(0, 8);
registerLiveAgent(agentId);
// When — PUT with apply=staged
ResponseEntity<String> response = putConfig("staged");
// Then — HTTP 200
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
// And — DB has the new config
ApplicationConfig saved = configRepository
.findByApplicationAndEnvironment(appSlug, envSlug)
.orElseThrow(() -> new AssertionError("Config not found in DB"));
assertThat(saved.getSamplingRate()).isEqualTo(0.1);
// And — NO CONFIG_UPDATE was pushed to any agent
verify(registryService, never())
.addGroupCommandWithReplies(eq(appSlug), eq(envSlug), eq(CommandType.CONFIG_UPDATE), any());
}
@Test
void putConfig_live_savesAndPushes() {
// Given — one LIVE agent registered for (appSlug, envSlug)
String agentId = "live-agent-" + UUID.randomUUID().toString().substring(0, 8);
registerLiveAgent(agentId);
// When — PUT without apply param (default is live)
ResponseEntity<String> response = putConfig(null);
// Then — HTTP 200
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
// And — DB has the new config
ApplicationConfig saved = configRepository
.findByApplicationAndEnvironment(appSlug, envSlug)
.orElseThrow(() -> new AssertionError("Config not found in DB"));
assertThat(saved.getSamplingRate()).isEqualTo(0.1);
// And — CONFIG_UPDATE was pushed (addGroupCommandWithReplies called once)
verify(registryService)
.addGroupCommandWithReplies(eq(appSlug), eq(envSlug), eq(CommandType.CONFIG_UPDATE), any());
}
@Test
void putConfig_liveExplicit_savesAndPushes() {
// Same as above but with explicit apply=live
String agentId = "live-explicit-" + UUID.randomUUID().toString().substring(0, 8);
registerLiveAgent(agentId);
ResponseEntity<String> response = putConfig("live");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
verify(registryService)
.addGroupCommandWithReplies(eq(appSlug), eq(envSlug), eq(CommandType.CONFIG_UPDATE), any());
}
@Test
void putConfig_unknownApplyValue_returns400() {
ResponseEntity<String> response = putConfig("BOGUS");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
int auditCount = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM audit_log WHERE target = ?", Integer.class, appSlug);
assertThat(auditCount).isZero();
}
@Test
void putConfig_staged_auditActionIsStagedAppConfig() {
registerLiveAgent("audit-agent-" + UUID.randomUUID().toString().substring(0, 8));
ResponseEntity<String> response = putConfig("staged");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<String> actions = jdbcTemplate.queryForList(
"SELECT action FROM audit_log WHERE target = ? ORDER BY timestamp DESC",
String.class, appSlug);
assertThat(actions).hasSize(1);
assertThat(actions.get(0)).isEqualTo("stage_app_config");
}
@Test
void putConfig_live_auditActionIsUpdateAppConfig() {
registerLiveAgent("audit-agent-live-" + UUID.randomUUID().toString().substring(0, 8));
ResponseEntity<String> response = putConfig(null);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<String> actions = jdbcTemplate.queryForList(
"SELECT action FROM audit_log WHERE target = ? ORDER BY timestamp DESC",
String.class, appSlug);
assertThat(actions).hasSize(1);
assertThat(actions.get(0)).isEqualTo("update_app_config");
}
}

View File

@@ -3,6 +3,7 @@ package com.cameleer.server.app.controller;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.cameleer.server.core.ingestion.IngestionService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -13,6 +14,8 @@ import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.TestPropertySource;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
@@ -45,10 +48,18 @@ class BackpressureIT extends AbstractPostgresIT {
@BeforeEach
void setUp() {
// Lift max_agents cap so this IT (which registers an agent) isn't gated by license
// enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT.
securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100));
String jwt = securityHelper.registerTestAgent("test-agent-backpressure-it");
authHeaders = securityHelper.authHeaders(jwt);
}
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
@Test
void whenMetricsBufferFull_returns503WithRetryAfter() {
// Fill the metrics buffer completely with a batch of 5

View File

@@ -0,0 +1,268 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
class DeploymentControllerAuditIT extends AbstractPostgresIT {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private TestSecurityHelper securityHelper;
private String aliceJwt;
private String adminJwt;
private String appSlug;
private String versionId;
@BeforeEach
void setUp() throws Exception {
// Mint JWT for alice (OPERATOR) — subject must start with "user:" for JwtAuthenticationFilter
aliceJwt = securityHelper.createToken("user:alice", "user", List.of("OPERATOR"));
adminJwt = securityHelper.adminToken();
// Lift default-tier caps so the promote-target env + apps can be created via the API,
// and lift compute caps so the async DeploymentExecutor PRE_FLIGHT cap check (T24)
// doesn't fail the deployment before audit assertions complete on long-running runs.
securityHelper.installSyntheticUnsignedLicense(Map.of(
"max_environments", 100,
"max_apps", 100,
"max_total_cpu_millis", 100_000,
"max_total_memory_mb", 100_000,
"max_total_replicas", 100));
// Clean up deployment-related tables and test-created environments
jdbcTemplate.update("DELETE FROM deployments");
jdbcTemplate.update("DELETE FROM app_versions");
jdbcTemplate.update("DELETE FROM apps");
jdbcTemplate.update("DELETE FROM environments WHERE slug LIKE 'promote-target-%'");
jdbcTemplate.update("DELETE FROM audit_log");
// Ensure alice exists in the users table (required for deployments.created_by FK)
jdbcTemplate.update(
"INSERT INTO users (user_id, provider, display_name) VALUES ('alice', 'local', 'Alice Test') ON CONFLICT (user_id) DO NOTHING");
// Create app in the seeded "default" environment
appSlug = "audit-test-" + UUID.randomUUID().toString().substring(0, 8);
String appJson = String.format("""
{"slug": "%s", "displayName": "Audit Test App"}
""", appSlug);
ResponseEntity<String> appResponse = restTemplate.exchange(
"/api/v1/environments/default/apps", HttpMethod.POST,
new HttpEntity<>(appJson, authHeaders(aliceJwt)),
String.class);
assertThat(appResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
// Upload a JAR version
byte[] jarContent = "fake-jar-for-audit-test".getBytes();
ByteArrayResource resource = new ByteArrayResource(jarContent) {
@Override
public String getFilename() {
return "audit-test.jar";
}
};
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("file", resource);
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + aliceJwt);
headers.set("X-Cameleer-Protocol-Version", "1");
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
ResponseEntity<String> versionResponse = restTemplate.exchange(
"/api/v1/environments/default/apps/" + appSlug + "/versions", HttpMethod.POST,
new HttpEntity<>(body, headers),
String.class);
assertThat(versionResponse.getStatusCode().is2xxSuccessful()).isTrue();
versionId = objectMapper.readTree(versionResponse.getBody()).path("id").asText();
}
@org.junit.jupiter.api.AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
@Test
void deploy_writes_audit_row_with_DEPLOYMENT_category_and_alice_actor() throws Exception {
String json = String.format("""
{"appVersionId": "%s"}
""", versionId);
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/environments/default/apps/" + appSlug + "/deployments", HttpMethod.POST,
new HttpEntity<>(json, authHeaders(aliceJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
Map<String, Object> row = queryAuditRow("deploy_app");
assertThat(row).isNotNull();
assertThat(row.get("username")).isEqualTo("alice");
assertThat(row.get("action")).isEqualTo("deploy_app");
assertThat(row.get("category")).isEqualTo("DEPLOYMENT");
assertThat(row.get("result")).isEqualTo("SUCCESS");
assertThat(row.get("target")).isNotNull();
assertThat(row.get("target").toString()).isNotBlank();
}
@Test
void stop_writes_audit_row() throws Exception {
// First deploy
String deployJson = String.format("""
{"appVersionId": "%s"}
""", versionId);
ResponseEntity<String> deployResponse = restTemplate.exchange(
"/api/v1/environments/default/apps/" + appSlug + "/deployments", HttpMethod.POST,
new HttpEntity<>(deployJson, authHeaders(aliceJwt)),
String.class);
assertThat(deployResponse.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
String deploymentId = objectMapper.readTree(deployResponse.getBody()).path("id").asText();
// Clear audit log to isolate stop audit row
jdbcTemplate.update("DELETE FROM audit_log");
// Stop the deployment
ResponseEntity<String> stopResponse = restTemplate.exchange(
"/api/v1/environments/default/apps/" + appSlug + "/deployments/" + deploymentId + "/stop",
HttpMethod.POST,
new HttpEntity<>(authHeadersNoBody(aliceJwt)),
String.class);
assertThat(stopResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
Map<String, Object> row = queryAuditRow("stop_deployment");
assertThat(row).isNotNull();
assertThat(row.get("username")).isEqualTo("alice");
assertThat(row.get("action")).isEqualTo("stop_deployment");
assertThat(row.get("category")).isEqualTo("DEPLOYMENT");
assertThat(row.get("result")).isEqualTo("SUCCESS");
assertThat(row.get("target").toString()).isEqualTo(deploymentId);
}
@Test
void promote_writes_audit_row() throws Exception {
// Create a second environment for promotion target
String targetEnvSlug = "promote-target-" + UUID.randomUUID().toString().substring(0, 8);
String envJson = String.format("""
{"slug": "%s", "displayName": "Promote Target Env"}
""", targetEnvSlug);
ResponseEntity<String> envResponse = restTemplate.exchange(
"/api/v1/admin/environments", HttpMethod.POST,
new HttpEntity<>(envJson, authHeaders(adminJwt)),
String.class);
assertThat(envResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
// Create the same app slug in the target environment
String appJson = String.format("""
{"slug": "%s", "displayName": "Audit Test App (target)"}
""", appSlug);
ResponseEntity<String> targetAppResponse = restTemplate.exchange(
"/api/v1/environments/" + targetEnvSlug + "/apps", HttpMethod.POST,
new HttpEntity<>(appJson, authHeaders(aliceJwt)),
String.class);
assertThat(targetAppResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
// Deploy in source (default) env
String deployJson = String.format("""
{"appVersionId": "%s"}
""", versionId);
ResponseEntity<String> deployResponse = restTemplate.exchange(
"/api/v1/environments/default/apps/" + appSlug + "/deployments", HttpMethod.POST,
new HttpEntity<>(deployJson, authHeaders(aliceJwt)),
String.class);
assertThat(deployResponse.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
String deploymentId = objectMapper.readTree(deployResponse.getBody()).path("id").asText();
// Clear audit log to isolate promote audit row
jdbcTemplate.update("DELETE FROM audit_log");
// Promote to target env
String promoteJson = String.format("""
{"targetEnvironment": "%s"}
""", targetEnvSlug);
ResponseEntity<String> promoteResponse = restTemplate.exchange(
"/api/v1/environments/default/apps/" + appSlug + "/deployments/" + deploymentId + "/promote",
HttpMethod.POST,
new HttpEntity<>(promoteJson, authHeaders(aliceJwt)),
String.class);
assertThat(promoteResponse.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
Map<String, Object> row = queryAuditRow("promote_deployment");
assertThat(row).isNotNull();
assertThat(row.get("username")).isEqualTo("alice");
assertThat(row.get("action")).isEqualTo("promote_deployment");
assertThat(row.get("category")).isEqualTo("DEPLOYMENT");
assertThat(row.get("result")).isEqualTo("SUCCESS");
assertThat(row.get("target")).isNotNull();
assertThat(row.get("target").toString()).isNotBlank();
}
@Test
void deploy_with_unknown_appVersion_writes_FAILURE_audit_row() throws Exception {
String unknownVersionId = UUID.randomUUID().toString();
String json = String.format("""
{"appVersionId": "%s"}
""", unknownVersionId);
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/environments/default/apps/" + appSlug + "/deployments", HttpMethod.POST,
new HttpEntity<>(json, authHeaders(aliceJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
Map<String, Object> row = queryAuditRow("deploy_app");
assertThat(row).isNotNull();
assertThat(row.get("username")).isEqualTo("alice");
assertThat(row.get("action")).isEqualTo("deploy_app");
assertThat(row.get("category")).isEqualTo("DEPLOYMENT");
assertThat(row.get("result")).isEqualTo("FAILURE");
}
// ---- helpers ----
private HttpHeaders authHeaders(String jwt) {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + jwt);
headers.set("X-Cameleer-Protocol-Version", "1");
headers.setContentType(MediaType.APPLICATION_JSON);
return headers;
}
private HttpHeaders authHeadersNoBody(String jwt) {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + jwt);
headers.set("X-Cameleer-Protocol-Version", "1");
return headers;
}
/** Query the most recent audit_log row for the given action. Returns null if not found. */
private Map<String, Object> queryAuditRow(String action) {
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT username, action, category, target, result FROM audit_log WHERE action = ? ORDER BY timestamp DESC LIMIT 1",
action);
return rows.isEmpty() ? null : rows.get(0);
}
}

View File

@@ -48,6 +48,10 @@ class DeploymentControllerIT extends AbstractPostgresIT {
jdbcTemplate.update("DELETE FROM app_versions");
jdbcTemplate.update("DELETE FROM apps");
// Ensure test-operator exists in users table (required for deployments.created_by FK)
jdbcTemplate.update(
"INSERT INTO users (user_id, provider, display_name) VALUES ('test-operator', 'local', 'Test Operator') ON CONFLICT (user_id) DO NOTHING");
// Get default environment ID
ResponseEntity<String> envResponse = restTemplate.exchange(
"/api/v1/admin/environments", HttpMethod.GET,

View File

@@ -4,6 +4,7 @@ import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
@@ -15,6 +16,8 @@ import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import java.util.Map;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
@@ -49,6 +52,9 @@ class DetailControllerIT extends AbstractPostgresIT {
*/
@BeforeAll
void seedTestData() {
// Lift max_agents cap so this IT (which registers an agent) isn't gated by license
// enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT.
securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100));
jwt = securityHelper.registerTestAgent("test-agent-detail-it");
viewerJwt = securityHelper.viewerToken();
@@ -231,4 +237,9 @@ class DetailControllerIT extends AbstractPostgresIT {
new HttpEntity<>(headers),
String.class);
}
@AfterAll
void tearDown() {
securityHelper.clearTestLicense();
}
}

View File

@@ -2,6 +2,7 @@ package com.cameleer.server.app.controller;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -12,6 +13,8 @@ import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import java.util.Map;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
@@ -29,11 +32,19 @@ class DiagramControllerIT extends AbstractPostgresIT {
@BeforeEach
void setUp() {
// Lift max_agents cap so this IT (which registers an agent) isn't gated by license
// enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT.
securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100));
String jwt = securityHelper.registerTestAgent("test-agent-diagram-it");
authHeaders = securityHelper.authHeaders(jwt);
viewerHeaders = securityHelper.authHeadersNoBody(securityHelper.viewerToken());
}
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
@Test
void postSingleDiagram_returns202() {
String json = """

View File

@@ -4,6 +4,7 @@ import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -14,6 +15,8 @@ import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import java.util.Map;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
@@ -41,6 +44,9 @@ class DiagramRenderControllerIT extends AbstractPostgresIT {
@BeforeEach
void seedDiagram() {
// Lift max_agents cap so this IT (which registers an agent) isn't gated by license
// enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT.
securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100));
jwt = securityHelper.registerTestAgent("test-agent-diagram-render-it");
viewerJwt = securityHelper.viewerToken();
@@ -115,6 +121,11 @@ class DiagramRenderControllerIT extends AbstractPostgresIT {
});
}
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
@Test
void getSvg_withAcceptHeader_returnsSvg() {
HttpHeaders headers = securityHelper.authHeadersNoBody(viewerJwt);
@@ -166,6 +177,157 @@ class DiagramRenderControllerIT extends AbstractPostgresIT {
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
@Test
void findByAppAndRoute_returnsLatestDiagram_noLiveAgentPrereq() {
// The env-scoped /routes/{routeId}/diagram endpoint no longer depends
// on the agent registry — routes whose publishing agents have been
// removed must still resolve. The seed step stored a diagram for
// route "render-test-route" under app "test-group" / env "default",
// so the same lookup must succeed even though the registry-driven
// "find agents for app" path used to be a hard 404 prerequisite.
HttpHeaders headers = securityHelper.authHeadersNoBody(viewerJwt);
headers.set("Accept", "application/json");
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/environments/default/apps/test-group/routes/render-test-route/diagram",
HttpMethod.GET,
new HttpEntity<>(headers),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).contains("nodes");
assertThat(response.getBody()).contains("edges");
}
@Test
void findByAppAndRoute_returns404ForUnknownRoute() {
HttpHeaders headers = securityHelper.authHeadersNoBody(viewerJwt);
headers.set("Accept", "application/json");
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/environments/default/apps/test-group/routes/nonexistent-route/diagram",
HttpMethod.GET,
new HttpEntity<>(headers),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
@Test
void exchangeDiagramHash_pinsPointInTimeEvenAfterNewerVersion() throws Exception {
// Point-in-time guarantee: an execution's stored diagramContentHash
// must keep resolving to the route shape captured at execution time,
// even after a newer diagram version for the same route is stored.
// Content-hash addressing + never-delete of route_diagrams makes this
// automatic — this test locks the invariant in.
HttpHeaders viewerHeaders = securityHelper.authHeadersNoBody(viewerJwt);
viewerHeaders.set("Accept", "application/json");
// Snapshot the pinned v1 render via the flat content-hash endpoint
// BEFORE a newer version is stored, so the post-v2 fetch can compare
// byte-for-byte.
ResponseEntity<String> pinnedBefore = restTemplate.exchange(
"/api/v1/diagrams/{hash}/render",
HttpMethod.GET,
new HttpEntity<>(viewerHeaders),
String.class,
contentHash);
assertThat(pinnedBefore.getStatusCode()).isEqualTo(HttpStatus.OK);
// Also snapshot the by-route "latest" render for the same route.
ResponseEntity<String> latestBefore = restTemplate.exchange(
"/api/v1/environments/default/apps/test-group/routes/render-test-route/diagram",
HttpMethod.GET,
new HttpEntity<>(viewerHeaders),
String.class);
assertThat(latestBefore.getStatusCode()).isEqualTo(HttpStatus.OK);
// Store a materially different v2 for the same (app, env, route).
// The renderer walks the `root` tree (not the legacy flat `nodes`
// list that the seed payload uses), so v2 uses the tree shape and
// will render non-empty output — letting us detect the version flip.
String newerDiagramJson = """
{
"routeId": "render-test-route",
"description": "v2 with extra step",
"version": 2,
"root": {
"id": "n1",
"type": "ENDPOINT",
"label": "timer:tick-v2",
"children": [
{
"id": "n2",
"type": "BEAN",
"label": "myBeanV2",
"children": [
{
"id": "n3",
"type": "TO",
"label": "log:out-v2",
"children": [
{"id": "n4", "type": "TO", "label": "log:audit"}
]
}
]
}
]
},
"edges": [
{"source": "n1", "target": "n2", "edgeType": "FLOW"},
{"source": "n2", "target": "n3", "edgeType": "FLOW"},
{"source": "n3", "target": "n4", "edgeType": "FLOW"}
]
}
""";
restTemplate.postForEntity(
"/api/v1/data/diagrams",
new HttpEntity<>(newerDiagramJson, securityHelper.authHeaders(jwt)),
String.class);
// Invariant 1: The execution's stored diagramContentHash must not
// drift — exchanges stay pinned to the version captured at ingest.
ResponseEntity<String> detailAfter = restTemplate.exchange(
"/api/v1/environments/default/executions?correlationId=render-probe-corr",
HttpMethod.GET,
new HttpEntity<>(viewerHeaders),
String.class);
JsonNode search = objectMapper.readTree(detailAfter.getBody());
String execId = search.get("data").get(0).get("executionId").asText();
ResponseEntity<String> exec = restTemplate.exchange(
"/api/v1/executions/" + execId,
HttpMethod.GET,
new HttpEntity<>(viewerHeaders),
String.class);
JsonNode execBody = objectMapper.readTree(exec.getBody());
assertThat(execBody.path("diagramContentHash").asText()).isEqualTo(contentHash);
// Invariant 2: The pinned render (by H1) must be byte-identical
// before and after v2 is stored — content-hash addressing is stable.
ResponseEntity<String> pinnedAfter = restTemplate.exchange(
"/api/v1/diagrams/{hash}/render",
HttpMethod.GET,
new HttpEntity<>(viewerHeaders),
String.class,
contentHash);
assertThat(pinnedAfter.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(pinnedAfter.getBody()).isEqualTo(pinnedBefore.getBody());
// Invariant 3: The by-route "latest" endpoint must now surface v2,
// so its body differs from the pre-v2 snapshot. Retry briefly to
// absorb the diagram-ingest flush path.
await().atMost(20, SECONDS).untilAsserted(() -> {
ResponseEntity<String> latestAfter = restTemplate.exchange(
"/api/v1/environments/default/apps/test-group/routes/render-test-route/diagram",
HttpMethod.GET,
new HttpEntity<>(viewerHeaders),
String.class);
assertThat(latestAfter.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(latestAfter.getBody()).isNotEqualTo(latestBefore.getBody());
assertThat(latestAfter.getBody()).contains("myBeanV2");
});
}
@Test
void getWithNoAcceptHeader_defaultsToSvg() {
HttpHeaders headers = securityHelper.authHeadersNoBody(viewerJwt);

Some files were not shown because too many files have changed in this diff Show More