From 5b9ae4fa0270964354e8b72e3c3e1657ab23d02e Mon Sep 17 00:00:00 2001 From: jedarden Date: Sun, 19 Apr 2026 12:53:37 -0400 Subject: [PATCH] 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 --- charts/miroir/templates/_helpers.tpl | 14 ++ charts/miroir/templates/miroir-validate.yaml | 5 + ...ad-admin-login-rate-limit-local-multi.yaml | 9 + charts/miroir/tests/bad-hpa-no-redis.yaml | 8 + .../miroir/tests/bad-hpa-single-replica.yaml | 8 + .../tests/bad-scoped-key-rotate-gt-max.yaml | 9 + .../tests/bad-scoped-key-rotate-gte-max.yaml | 9 + .../bad-search-ui-rate-limit-local-multi.yaml | 9 + charts/miroir/tests/good-dev-no-ui.yaml | 6 + charts/miroir/tests/good-production.yaml | 18 ++ charts/miroir/values.schema.json | 218 ++++++++++++++++-- 11 files changed, 293 insertions(+), 20 deletions(-) create mode 100644 charts/miroir/templates/miroir-validate.yaml create mode 100644 charts/miroir/tests/bad-admin-login-rate-limit-local-multi.yaml create mode 100644 charts/miroir/tests/bad-hpa-no-redis.yaml create mode 100644 charts/miroir/tests/bad-hpa-single-replica.yaml create mode 100644 charts/miroir/tests/bad-scoped-key-rotate-gt-max.yaml create mode 100644 charts/miroir/tests/bad-scoped-key-rotate-gte-max.yaml create mode 100644 charts/miroir/tests/bad-search-ui-rate-limit-local-multi.yaml create mode 100644 charts/miroir/tests/good-dev-no-ui.yaml create mode 100644 charts/miroir/tests/good-production.yaml diff --git a/charts/miroir/templates/_helpers.tpl b/charts/miroir/templates/_helpers.tpl index efa4341..814a2d3 100644 --- a/charts/miroir/templates/_helpers.tpl +++ b/charts/miroir/templates/_helpers.tpl @@ -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 -}} diff --git a/charts/miroir/templates/miroir-validate.yaml b/charts/miroir/templates/miroir-validate.yaml new file mode 100644 index 0000000..2c90126 --- /dev/null +++ b/charts/miroir/templates/miroir-validate.yaml @@ -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" . -}} diff --git a/charts/miroir/tests/bad-admin-login-rate-limit-local-multi.yaml b/charts/miroir/tests/bad-admin-login-rate-limit-local-multi.yaml new file mode 100644 index 0000000..8fbedb1 --- /dev/null +++ b/charts/miroir/tests/bad-admin-login-rate-limit-local-multi.yaml @@ -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 diff --git a/charts/miroir/tests/bad-hpa-no-redis.yaml b/charts/miroir/tests/bad-hpa-no-redis.yaml new file mode 100644 index 0000000..531480d --- /dev/null +++ b/charts/miroir/tests/bad-hpa-no-redis.yaml @@ -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 diff --git a/charts/miroir/tests/bad-hpa-single-replica.yaml b/charts/miroir/tests/bad-hpa-single-replica.yaml new file mode 100644 index 0000000..713a479 --- /dev/null +++ b/charts/miroir/tests/bad-hpa-single-replica.yaml @@ -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 diff --git a/charts/miroir/tests/bad-scoped-key-rotate-gt-max.yaml b/charts/miroir/tests/bad-scoped-key-rotate-gt-max.yaml new file mode 100644 index 0000000..b4330f3 --- /dev/null +++ b/charts/miroir/tests/bad-scoped-key-rotate-gt-max.yaml @@ -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 diff --git a/charts/miroir/tests/bad-scoped-key-rotate-gte-max.yaml b/charts/miroir/tests/bad-scoped-key-rotate-gte-max.yaml new file mode 100644 index 0000000..1a80236 --- /dev/null +++ b/charts/miroir/tests/bad-scoped-key-rotate-gte-max.yaml @@ -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 diff --git a/charts/miroir/tests/bad-search-ui-rate-limit-local-multi.yaml b/charts/miroir/tests/bad-search-ui-rate-limit-local-multi.yaml new file mode 100644 index 0000000..97a86e4 --- /dev/null +++ b/charts/miroir/tests/bad-search-ui-rate-limit-local-multi.yaml @@ -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 diff --git a/charts/miroir/tests/good-dev-no-ui.yaml b/charts/miroir/tests/good-dev-no-ui.yaml new file mode 100644 index 0000000..509a33c --- /dev/null +++ b/charts/miroir/tests/good-dev-no-ui.yaml @@ -0,0 +1,6 @@ +# Good: dev defaults (single replica, SQLite, no HPA, no UI) +# Expected: PASS +miroir: + replicas: 1 +taskStore: + backend: sqlite diff --git a/charts/miroir/tests/good-production.yaml b/charts/miroir/tests/good-production.yaml new file mode 100644 index 0000000..f88137a --- /dev/null +++ b/charts/miroir/tests/good-production.yaml @@ -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 diff --git a/charts/miroir/values.schema.json b/charts/miroir/values.schema.json index 5f4ff05..4ee2f05 100644 --- a/charts/miroir/values.schema.json +++ b/charts/miroir/values.schema.json @@ -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)" + } + ] }