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:
hsiegeln
2026-04-26 10:47:10 +02:00
parent cf84d80de7
commit ddc0b686c3
6 changed files with 188 additions and 0 deletions

View File

@@ -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() {}
}

View File

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

View File

@@ -0,0 +1,9 @@
package com.cameleer.server.core.license;
public enum LicenseState {
ABSENT,
ACTIVE,
GRACE,
EXPIRED,
INVALID
}

View File

@@ -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;
}
}

View File

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

View File

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