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