From ddc0b686c31f224e80bc086de609a563bda818a3 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 26 Apr 2026 10:47:10 +0200 Subject: [PATCH] feat(license): add LicenseLimits, DefaultTierLimits, LicenseStateMachine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../core/license/DefaultTierLimits.java | 30 ++++++++++ .../server/core/license/LicenseLimits.java | 36 ++++++++++++ .../server/core/license/LicenseState.java | 9 +++ .../core/license/LicenseStateMachine.java | 26 +++++++++ .../core/license/DefaultTierLimitsTest.java | 30 ++++++++++ .../core/license/LicenseStateMachineTest.java | 57 +++++++++++++++++++ 6 files changed, 188 insertions(+) create mode 100644 cameleer-server-core/src/main/java/com/cameleer/server/core/license/DefaultTierLimits.java create mode 100644 cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseLimits.java create mode 100644 cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseState.java create mode 100644 cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseStateMachine.java create mode 100644 cameleer-server-core/src/test/java/com/cameleer/server/core/license/DefaultTierLimitsTest.java create mode 100644 cameleer-server-core/src/test/java/com/cameleer/server/core/license/LicenseStateMachineTest.java diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/license/DefaultTierLimits.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/license/DefaultTierLimits.java new file mode 100644 index 00000000..48b4c655 --- /dev/null +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/license/DefaultTierLimits.java @@ -0,0 +1,30 @@ +package com.cameleer.server.core.license; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +public final class DefaultTierLimits { + + public static final Map DEFAULTS; + + static { + Map m = new LinkedHashMap<>(); + m.put("max_environments", 1); + m.put("max_apps", 3); + m.put("max_agents", 5); + m.put("max_users", 3); + m.put("max_outbound_connections", 1); + m.put("max_alert_rules", 2); + m.put("max_total_cpu_millis", 2000); + m.put("max_total_memory_mb", 2048); + m.put("max_total_replicas", 5); + m.put("max_execution_retention_days", 1); + m.put("max_log_retention_days", 1); + m.put("max_metric_retention_days", 1); + m.put("max_jar_retention_count", 3); + DEFAULTS = Collections.unmodifiableMap(m); + } + + private DefaultTierLimits() {} +} diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseLimits.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseLimits.java new file mode 100644 index 00000000..48e29f18 --- /dev/null +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseLimits.java @@ -0,0 +1,36 @@ +package com.cameleer.server.core.license; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +public record LicenseLimits(Map values) { + + public LicenseLimits { + Objects.requireNonNull(values, "values"); + } + + public static LicenseLimits defaultsOnly() { + return new LicenseLimits(DefaultTierLimits.DEFAULTS); + } + + public static LicenseLimits mergeOverDefaults(Map overrides) { + Map merged = new LinkedHashMap<>(DefaultTierLimits.DEFAULTS); + if (overrides != null) merged.putAll(overrides); + return new LicenseLimits(Collections.unmodifiableMap(merged)); + } + + public int get(String key) { + Integer v = values.get(key); + if (v == null) { + throw new IllegalArgumentException("Unknown license limit key: " + key); + } + return v; + } + + public boolean isDefaultSourced(String key, LicenseInfo license) { + if (license == null) return true; + return !license.limits().containsKey(key); + } +} diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseState.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseState.java new file mode 100644 index 00000000..711c367a --- /dev/null +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseState.java @@ -0,0 +1,9 @@ +package com.cameleer.server.core.license; + +public enum LicenseState { + ABSENT, + ACTIVE, + GRACE, + EXPIRED, + INVALID +} diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseStateMachine.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseStateMachine.java new file mode 100644 index 00000000..12f12a3f --- /dev/null +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/license/LicenseStateMachine.java @@ -0,0 +1,26 @@ +package com.cameleer.server.core.license; + +public final class LicenseStateMachine { + + private LicenseStateMachine() {} + + /** + * @param license parsed license, or null if no license is loaded + * @param invalidReason non-null if the last validation attempt failed + */ + public static LicenseState classify(LicenseInfo license, String invalidReason) { + if (invalidReason != null) { + return LicenseState.INVALID; + } + if (license == null) { + return LicenseState.ABSENT; + } + if (!license.isAfterRawExpiry()) { + return LicenseState.ACTIVE; + } + if (!license.isExpired()) { + return LicenseState.GRACE; + } + return LicenseState.EXPIRED; + } +} diff --git a/cameleer-server-core/src/test/java/com/cameleer/server/core/license/DefaultTierLimitsTest.java b/cameleer-server-core/src/test/java/com/cameleer/server/core/license/DefaultTierLimitsTest.java new file mode 100644 index 00000000..398fe60b --- /dev/null +++ b/cameleer-server-core/src/test/java/com/cameleer/server/core/license/DefaultTierLimitsTest.java @@ -0,0 +1,30 @@ +package com.cameleer.server.core.license; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class DefaultTierLimitsTest { + + @Test + void allDocumentedKeysHaveDefaults() { + for (String key : new String[]{ + "max_environments", "max_apps", "max_agents", "max_users", + "max_outbound_connections", "max_alert_rules", + "max_total_cpu_millis", "max_total_memory_mb", "max_total_replicas", + "max_execution_retention_days", "max_log_retention_days", + "max_metric_retention_days", "max_jar_retention_count" + }) { + assertThat(DefaultTierLimits.DEFAULTS).containsKey(key); + } + } + + @Test + void specificValues() { + assertThat(DefaultTierLimits.DEFAULTS.get("max_environments")).isEqualTo(1); + assertThat(DefaultTierLimits.DEFAULTS.get("max_apps")).isEqualTo(3); + assertThat(DefaultTierLimits.DEFAULTS.get("max_agents")).isEqualTo(5); + assertThat(DefaultTierLimits.DEFAULTS.get("max_total_cpu_millis")).isEqualTo(2000); + assertThat(DefaultTierLimits.DEFAULTS.get("max_log_retention_days")).isEqualTo(1); + } +} diff --git a/cameleer-server-core/src/test/java/com/cameleer/server/core/license/LicenseStateMachineTest.java b/cameleer-server-core/src/test/java/com/cameleer/server/core/license/LicenseStateMachineTest.java new file mode 100644 index 00000000..09429499 --- /dev/null +++ b/cameleer-server-core/src/test/java/com/cameleer/server/core/license/LicenseStateMachineTest.java @@ -0,0 +1,57 @@ +package com.cameleer.server.core.license; + +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class LicenseStateMachineTest { + + @Test + void noLicense_isAbsent() { + assertThat(LicenseStateMachine.classify(null, null)).isEqualTo(LicenseState.ABSENT); + } + + @Test + void invalidReason_isInvalid() { + assertThat(LicenseStateMachine.classify(null, "signature failed")).isEqualTo(LicenseState.INVALID); + } + + @Test + void activeBeforeExp() { + LicenseInfo info = info(Instant.now().plusSeconds(86400), 0); + assertThat(LicenseStateMachine.classify(info, null)).isEqualTo(LicenseState.ACTIVE); + } + + @Test + void graceWithinGracePeriod() { + LicenseInfo info = info(Instant.now().minusSeconds(86400), 7); + assertThat(LicenseStateMachine.classify(info, null)).isEqualTo(LicenseState.GRACE); + } + + @Test + void expiredAfterGrace() { + LicenseInfo info = info(Instant.now().minusSeconds(8L * 86400), 7); + assertThat(LicenseStateMachine.classify(info, null)).isEqualTo(LicenseState.EXPIRED); + } + + @Test + void expiredImmediatelyWithZeroGrace() { + LicenseInfo info = info(Instant.now().minusSeconds(60), 0); + assertThat(LicenseStateMachine.classify(info, null)).isEqualTo(LicenseState.EXPIRED); + } + + @Test + void invalidWinsOverPresentLicense() { + LicenseInfo info = info(Instant.now().plusSeconds(86400), 0); + assertThat(LicenseStateMachine.classify(info, "tenant mismatch")).isEqualTo(LicenseState.INVALID); + } + + private LicenseInfo info(Instant exp, int graceDays) { + return new LicenseInfo(UUID.randomUUID(), "acme", null, Map.of(), + Instant.now().minusSeconds(3600), exp, graceDays); + } +}