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