From f291d7c24d1adf48b973a13b0b5049482bd37aa4 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 26 Apr 2026 12:47:59 +0200 Subject: [PATCH] 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) --- .../app/license/LicenseUsageReader.java | 67 +++++++++++++++++++ .../app/license/LicenseUsageReaderIT.java | 20 ++++++ 2 files changed, 87 insertions(+) create mode 100644 cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseUsageReader.java create mode 100644 cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseUsageReaderIT.java diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseUsageReader.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseUsageReader.java new file mode 100644 index 00000000..049c6ec0 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/license/LicenseUsageReader.java @@ -0,0 +1,67 @@ +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. + * + *

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.

+ * + *

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

+ */ +@Component +public class LicenseUsageReader { + + private final JdbcTemplate jdbc; + + public LicenseUsageReader(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + public Map snapshot() { + Map 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 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; + } + + /** 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); + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseUsageReaderIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseUsageReaderIT.java new file mode 100644 index 00000000..896c8a84 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseUsageReaderIT.java @@ -0,0 +1,20 @@ +package com.cameleer.server.app.license; + +import com.cameleer.server.app.AbstractPostgresIT; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import static org.assertj.core.api.Assertions.assertThat; + +class LicenseUsageReaderIT extends AbstractPostgresIT { + + @Autowired LicenseUsageReader reader; + + @Test + void emptyDb_returnsZeros() { + var snap = reader.snapshot(); + assertThat(snap.get("max_apps")).isEqualTo(0L); + assertThat(snap.get("max_environments")).isLessThanOrEqualTo(1L); // V1 seeds default env + assertThat(snap.get("max_total_cpu_millis")).isEqualTo(0L); + } +}