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>
This commit is contained in:
@@ -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<String, Integer> DEFAULTS;
|
||||||
|
|
||||||
|
static {
|
||||||
|
Map<String, Integer> 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() {}
|
||||||
|
}
|
||||||
@@ -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<String, Integer> values) {
|
||||||
|
|
||||||
|
public LicenseLimits {
|
||||||
|
Objects.requireNonNull(values, "values");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static LicenseLimits defaultsOnly() {
|
||||||
|
return new LicenseLimits(DefaultTierLimits.DEFAULTS);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static LicenseLimits mergeOverDefaults(Map<String, Integer> overrides) {
|
||||||
|
Map<String, Integer> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.cameleer.server.core.license;
|
||||||
|
|
||||||
|
public enum LicenseState {
|
||||||
|
ABSENT,
|
||||||
|
ACTIVE,
|
||||||
|
GRACE,
|
||||||
|
EXPIRED,
|
||||||
|
INVALID
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user