P8.3: Add values.schema.json rejection rules for incompatible configs

Schema-enforced rules (helm lint --strict):
- Rule 1: miroir.replicas > 1 requires taskStore.backend=redis
- Rule 2: hpa.enabled requires replicas >= 2 AND taskStore.backend=redis
- Rule 3: search_ui.rate_limit.backend=local rejected when replicas > 1
- Rule 4: admin_ui.login_rate_limit.backend=local rejected when replicas > 1

Template-enforced rule (helm template):
- Rule 5: scoped_key_rotate_before_expiry_days < scoped_key_max_age_days
  (JSON Schema draft-7 cannot compare sibling properties)

11 test cases: 7 bad configs rejected, 4 good configs pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-19 12:53:37 -04:00
parent 7c13091a27
commit 5b9ae4fa02
11 changed files with 293 additions and 20 deletions

View file

@ -188,3 +188,17 @@ Return "true" if CDC PVC should be created, "false" otherwise.
{{- define "miroir.cdcPvcEnabled" -}}
{{- if .Values.cdcPvc.enabled -}}true{{- else -}}false{{- end -}}
{{- end -}}
{{/*
Cross-field validations that JSON Schema draft-7 cannot express.
Rendered as an empty ConfigMap; fails template rendering on invalid config.
*/}}
{{- define "miroir.validate.values" -}}
{{- if .Values.search_ui -}}
{{- if and (hasKey .Values.search_ui "scoped_key_rotate_before_expiry_days") (hasKey .Values.search_ui "scoped_key_max_age_days") -}}
{{- if ge (int .Values.search_ui.scoped_key_rotate_before_expiry_days) (int .Values.search_ui.scoped_key_max_age_days) -}}
{{- fail (printf "search_ui.scoped_key_rotate_before_expiry_days (%d) must be strictly less than scoped_key_max_age_days (%d); otherwise rotation fires before/at key issuance, producing a continuous rotation loop" (int .Values.search_ui.scoped_key_rotate_before_expiry_days) (int .Values.search_ui.scoped_key_max_age_days)) -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- end -}}

View file

@ -0,0 +1,5 @@
{{/*
Render-time value validations (cross-field checks that JSON Schema cannot express).
This produces no output on success; calls fail() on invalid config.
*/}}
{{- include "miroir.validate.values" . -}}

View file

@ -0,0 +1,9 @@
# Rule 4 bad: admin_ui.login_rate_limit.backend=local with multiple replicas
# Expected: FAIL — local login rate limits not shared across pods
miroir:
replicas: 2
taskStore:
backend: redis
admin_ui:
login_rate_limit:
backend: local

View file

@ -0,0 +1,8 @@
# Rule 2 bad: HPA enabled with SQLite backend
# Expected: FAIL — HPA requires taskStore.backend=redis
miroir:
replicas: 2
taskStore:
backend: sqlite
hpa:
enabled: true

View file

@ -0,0 +1,8 @@
# Rule 2 bad: HPA enabled with only 1 replica
# Expected: FAIL — HPA requires miroir.replicas >= 2
miroir:
replicas: 1
taskStore:
backend: redis
hpa:
enabled: true

View file

@ -0,0 +1,9 @@
# Rule 5 bad: scoped_key_rotate_before_expiry_days > scoped_key_max_age_days
# Expected: FAIL — rotation window is negative, producing a continuous rotation loop
miroir:
replicas: 1
taskStore:
backend: sqlite
search_ui:
scoped_key_max_age_days: 30
scoped_key_rotate_before_expiry_days: 60

View file

@ -0,0 +1,9 @@
# Rule 5 bad: scoped_key_rotate_before_expiry_days >= scoped_key_max_age_days
# Expected: FAIL — rotation fires before/at key issuance, producing a continuous rotation loop
miroir:
replicas: 1
taskStore:
backend: sqlite
search_ui:
scoped_key_max_age_days: 30
scoped_key_rotate_before_expiry_days: 30

View file

@ -0,0 +1,9 @@
# Rule 3 bad: search_ui.rate_limit.backend=local with multiple replicas
# Expected: FAIL — local rate limiting with multi-replica gives per_ip × pod_count effective rate
miroir:
replicas: 2
taskStore:
backend: redis
search_ui:
rate_limit:
backend: local

View file

@ -0,0 +1,6 @@
# Good: dev defaults (single replica, SQLite, no HPA, no UI)
# Expected: PASS
miroir:
replicas: 1
taskStore:
backend: sqlite

View file

@ -0,0 +1,18 @@
# Good: full production config with all features enabled correctly
# Expected: PASS
miroir:
replicas: 3
taskStore:
backend: redis
hpa:
enabled: true
minReplicas: 3
maxReplicas: 10
search_ui:
scoped_key_max_age_days: 60
scoped_key_rotate_before_expiry_days: 30
rate_limit:
backend: redis
admin_ui:
login_rate_limit:
backend: redis

View file

@ -196,34 +196,212 @@
"size": { "type": "string" },
"storageClass": { "type": "string" }
}
},
"search_ui": {
"type": "object",
"properties": {
"enabled": { "type": "boolean" },
"scoped_key_max_age_days": { "type": "integer", "minimum": 2 },
"scoped_key_rotate_before_expiry_days": { "type": "integer", "minimum": 1 },
"rate_limit": {
"type": "object",
"properties": {
"backend": { "type": "string", "enum": ["local", "redis"] }
}
}
}
},
"admin_ui": {
"type": "object",
"properties": {
"enabled": { "type": "boolean" },
"login_rate_limit": {
"type": "object",
"properties": {
"backend": { "type": "string", "enum": ["local", "redis"] }
}
}
}
}
},
"if": {
"properties": {
"miroir": {
"allOf": [
{
"description": "Rule 1: miroir.replicas > 1 requires taskStore.backend: redis",
"if": {
"properties": {
"replicas": {
"type": "integer",
"exclusiveMinimum": 1
"miroir": {
"properties": {
"replicas": { "type": "integer", "exclusiveMinimum": 1 }
},
"required": ["replicas"]
}
},
"required": ["replicas"]
}
},
"required": ["miroir"]
},
"then": {
"properties": {
"taskStore": {
"required": ["miroir"]
},
"then": {
"properties": {
"backend": {
"const": "redis",
"errorMessage": "SQLite task store cannot run with multiple replicas; set taskStore.backend=redis"
"taskStore": {
"properties": {
"backend": {
"const": "redis",
"errorMessage": "SQLite task store cannot run with multiple replicas; set taskStore.backend=redis"
}
},
"required": ["backend"]
}
},
"required": ["backend"]
"required": ["taskStore"]
}
},
"required": ["taskStore"]
}
{
"description": "Rule 2: hpa.enabled requires replicas >= 2 AND taskStore.backend: redis",
"if": {
"properties": {
"hpa": {
"properties": {
"enabled": { "const": true }
},
"required": ["enabled"]
}
},
"required": ["hpa"]
},
"then": {
"allOf": [
{
"properties": {
"miroir": {
"properties": {
"replicas": {
"type": "integer",
"minimum": 2,
"errorMessage": "HPA requires miroir.replicas >= 2; increase replicas or disable hpa"
}
},
"required": ["replicas"]
}
},
"required": ["miroir"]
},
{
"properties": {
"taskStore": {
"properties": {
"backend": {
"const": "redis",
"errorMessage": "HPA requires taskStore.backend=redis; SQLite is single-writer and cannot be shared across autoscaled pods"
}
},
"required": ["backend"]
}
},
"required": ["taskStore"]
}
]
}
},
{
"description": "Rule 3: search_ui.rate_limit.backend: local rejected when miroir.replicas > 1",
"if": {
"allOf": [
{
"properties": {
"miroir": {
"properties": {
"replicas": { "type": "integer", "exclusiveMinimum": 1 }
},
"required": ["replicas"]
}
},
"required": ["miroir"]
},
{
"properties": {
"search_ui": {
"properties": {
"rate_limit": {
"properties": {
"backend": { "const": "local" }
},
"required": ["backend"]
}
},
"required": ["rate_limit"]
}
},
"required": ["search_ui"]
}
]
},
"then": {
"properties": {
"search_ui": {
"properties": {
"rate_limit": {
"properties": {
"backend": {
"not": { "const": "local" },
"description": "Local rate limiting is incompatible with multiple replicas — effective per_ip limit is multiplied by pod count. Use backend: redis instead."
}
}
}
}
}
}
}
},
{
"description": "Rule 4: admin_ui.login_rate_limit.backend: local rejected when miroir.replicas > 1",
"if": {
"allOf": [
{
"properties": {
"miroir": {
"properties": {
"replicas": { "type": "integer", "exclusiveMinimum": 1 }
},
"required": ["replicas"]
}
},
"required": ["miroir"]
},
{
"properties": {
"admin_ui": {
"properties": {
"login_rate_limit": {
"properties": {
"backend": { "const": "local" }
},
"required": ["backend"]
}
},
"required": ["login_rate_limit"]
}
},
"required": ["admin_ui"]
}
]
},
"then": {
"properties": {
"admin_ui": {
"properties": {
"login_rate_limit": {
"properties": {
"backend": {
"not": { "const": "local" },
"description": "Local login rate limiting is incompatible with multiple replicas — limits are not shared across pods. Use backend: redis instead."
}
}
}
}
}
}
}
},
{
"description": "Rule 5: scoped_key_rotate_before_expiry_days must be strictly less than scoped_key_max_age_days (enforced at render time via _helpers.tpl since JSON Schema draft-7 cannot compare sibling properties)"
}
]
}