diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 902fe72..8d8f688 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -40,3 +40,10 @@ repos: entry: ci/validate-no-secrets.sh language: system pass_filenames: false + - id: conftest_policies + name: OPA policy checks (conftest) + entry: conftest test --policy policy/ + language: system + types: [yaml] + exclude: ".*/charts/.*|.*/templates/.*|\\.woodpecker/.*" + pass_filenames: true diff --git a/apps/base/puppet/deployment_puppetboard.yaml b/apps/base/puppet/deployment_puppetboard.yaml index 1d8bb50..be16397 100644 --- a/apps/base/puppet/deployment_puppetboard.yaml +++ b/apps/base/puppet/deployment_puppetboard.yaml @@ -150,7 +150,7 @@ spec: memory: 350Mi cpu: 100m limits: - memory: 1024Mi + memory: 1Gi cpu: 500m securityContext: runAsNonRoot: true diff --git a/apps/base/puppet/deployment_puppetdb.yaml b/apps/base/puppet/deployment_puppetdb.yaml index 4b81e01..dc28e6c 100644 --- a/apps/base/puppet/deployment_puppetdb.yaml +++ b/apps/base/puppet/deployment_puppetdb.yaml @@ -35,7 +35,7 @@ spec: imagePullPolicy: IfNotPresent resources: limits: - cpu: 1 + cpu: "1" memory: 1536Mi requests: cpu: 250m diff --git a/apps/base/puppet/deployment_puppetserver-compiler.yaml b/apps/base/puppet/deployment_puppetserver-compiler.yaml index 4a0a08c..83810e4 100644 --- a/apps/base/puppet/deployment_puppetserver-compiler.yaml +++ b/apps/base/puppet/deployment_puppetserver-compiler.yaml @@ -31,11 +31,11 @@ spec: imagePullPolicy: IfNotPresent resources: limits: - cpu: 2 - memory: 3072Mi + cpu: "2" + memory: 3Gi requests: cpu: 500m - memory: 1024Mi + memory: 1Gi ports: - containerPort: 8140 name: puppetserver diff --git a/apps/base/puppet/deployment_puppetserver-master.yaml b/apps/base/puppet/deployment_puppetserver-master.yaml index a2a1230..8c6e1a2 100644 --- a/apps/base/puppet/deployment_puppetserver-master.yaml +++ b/apps/base/puppet/deployment_puppetserver-master.yaml @@ -35,11 +35,11 @@ spec: imagePullPolicy: IfNotPresent resources: limits: - cpu: 2 + cpu: "2" memory: 3500Mi requests: cpu: 250m - memory: 1024Mi + memory: 1Gi ports: - containerPort: 8140 name: puppetserver diff --git a/apps/base/reposync/cronjob_reposync-almalinux9-appstream.yaml b/apps/base/reposync/cronjob_reposync-almalinux9-appstream.yaml index fa1a9c7..db41cc4 100644 --- a/apps/base/reposync/cronjob_reposync-almalinux9-appstream.yaml +++ b/apps/base/reposync/cronjob_reposync-almalinux9-appstream.yaml @@ -53,7 +53,7 @@ spec: cpu: 500m memory: 1Gi limits: - cpu: 2000m + cpu: "2" memory: 4Gi volumeMounts: - name: repodata diff --git a/apps/base/reposync/cronjob_reposync-almalinux9-baseos.yaml b/apps/base/reposync/cronjob_reposync-almalinux9-baseos.yaml index 7d18703..c5bd236 100644 --- a/apps/base/reposync/cronjob_reposync-almalinux9-baseos.yaml +++ b/apps/base/reposync/cronjob_reposync-almalinux9-baseos.yaml @@ -56,7 +56,7 @@ spec: cpu: 500m memory: 1Gi limits: - cpu: 2000m + cpu: "2" memory: 4Gi volumeMounts: - name: repodata diff --git a/apps/base/reposync/cronjob_reposync-epel9.yaml b/apps/base/reposync/cronjob_reposync-epel9.yaml index b11095d..e6c63a9 100644 --- a/apps/base/reposync/cronjob_reposync-epel9.yaml +++ b/apps/base/reposync/cronjob_reposync-epel9.yaml @@ -53,7 +53,7 @@ spec: cpu: 500m memory: 1Gi limits: - cpu: 2000m + cpu: "2" memory: 4Gi volumeMounts: - name: repodata diff --git a/apps/base/reposync/cronjob_reposync-openvox7.yaml b/apps/base/reposync/cronjob_reposync-openvox7.yaml index b0fc005..bcf494e 100644 --- a/apps/base/reposync/cronjob_reposync-openvox7.yaml +++ b/apps/base/reposync/cronjob_reposync-openvox7.yaml @@ -52,7 +52,7 @@ spec: cpu: 500m memory: 1Gi limits: - cpu: 2000m + cpu: "2" memory: 4Gi volumeMounts: - name: repodata diff --git a/policy/gateway_api.rego b/policy/gateway_api.rego new file mode 100644 index 0000000..bb15aef --- /dev/null +++ b/policy/gateway_api.rego @@ -0,0 +1,94 @@ +package main + +# Gateway API resources require several fields to be set explicitly even though +# the Gateway API controller defaults them. ArgoCD diffs desired vs live by +# string comparison, so any field the controller defaults that is absent from +# the git manifest causes a permanent OutOfSync. +# +# Affected resources: +# HTTPRoute / TLSRoute — parentRefs and backendRefs (see PR #162, #165) +# Gateway — listeners[*].tls.certificateRefs (see PR #153) + +_route_kinds := {"HTTPRoute", "TLSRoute"} + +# ---- parentRefs: group must be "gateway.networking.k8s.io" ---- + +deny contains msg if { + _route_kinds[input.kind] + ref := input.spec.parentRefs[i] + object.get(ref, "group", null) != "gateway.networking.k8s.io" + msg := sprintf( + "%s %s/%s parentRefs[%d]: add 'group: gateway.networking.k8s.io' — controller defaults this field, causing ArgoCD OutOfSync when omitted", + [input.kind, input.metadata.namespace, input.metadata.name, i], + ) +} + +# ---- parentRefs: kind must be "Gateway" ---- + +deny contains msg if { + _route_kinds[input.kind] + ref := input.spec.parentRefs[i] + object.get(ref, "kind", null) != "Gateway" + msg := sprintf( + "%s %s/%s parentRefs[%d]: add 'kind: Gateway' — controller defaults this field, causing ArgoCD OutOfSync when omitted", + [input.kind, input.metadata.namespace, input.metadata.name, i], + ) +} + +# ---- backendRefs: group must be present (may be empty string "") ---- + +deny contains msg if { + _route_kinds[input.kind] + rule := input.spec.rules[ri] + ref := rule.backendRefs[bi] + not _has_key(ref, "group") + msg := sprintf( + "%s %s/%s rules[%d].backendRefs[%d]: add 'group: \"\"' — controller defaults this field, causing ArgoCD OutOfSync when omitted", + [input.kind, input.metadata.namespace, input.metadata.name, ri, bi], + ) +} + +# ---- backendRefs: kind must be "Service" ---- + +deny contains msg if { + _route_kinds[input.kind] + rule := input.spec.rules[ri] + ref := rule.backendRefs[bi] + object.get(ref, "kind", null) != "Service" + msg := sprintf( + "%s %s/%s rules[%d].backendRefs[%d]: add 'kind: Service' — controller defaults this field, causing ArgoCD OutOfSync when omitted", + [input.kind, input.metadata.namespace, input.metadata.name, ri, bi], + ) +} + +# ---- backendRefs: weight must be present ---- + +deny contains msg if { + _route_kinds[input.kind] + rule := input.spec.rules[ri] + ref := rule.backendRefs[bi] + not _has_key(ref, "weight") + msg := sprintf( + "%s %s/%s rules[%d].backendRefs[%d]: add 'weight: 1' — controller defaults this field, causing ArgoCD OutOfSync when omitted", + [input.kind, input.metadata.namespace, input.metadata.name, ri, bi], + ) +} + +# ---- Gateway certificateRefs: group must be present (may be empty string "") ---- + +deny contains msg if { + input.kind == "Gateway" + listener := input.spec.listeners[li] + ref := listener.tls.certificateRefs[ci] + not _has_key(ref, "group") + msg := sprintf( + "Gateway %s/%s listeners[%d].tls.certificateRefs[%d]: add 'group: \"\"' — admission webhook defaults this field, causing ArgoCD OutOfSync when omitted", + [input.metadata.namespace, input.metadata.name, li, ci], + ) +} + +# ---- Helper: key presence check (works for null, "", and any defined value) ---- + +_has_key(obj, key) if { + _ = obj[key] +} diff --git a/policy/no_ingress.rego b/policy/no_ingress.rego new file mode 100644 index 0000000..24ffe8f --- /dev/null +++ b/policy/no_ingress.rego @@ -0,0 +1,13 @@ +package main + +# Deny all Kubernetes Ingress resources. +# This cluster uses Gateway API (HTTPRoute + Gateway) for ingress routing. +# Ingress is the legacy API and must not be added. + +deny contains msg if { + input.kind == "Ingress" + msg := sprintf( + "%s/%s: Ingress resources are forbidden — use Gateway API HTTPRoute instead", + [input.metadata.namespace, input.metadata.name], + ) +} diff --git a/policy/resource_normalization.rego b/policy/resource_normalization.rego new file mode 100644 index 0000000..2e2f0dd --- /dev/null +++ b/policy/resource_normalization.rego @@ -0,0 +1,173 @@ +package main + +# Kubernetes normalizes resource quantity values on write. ArgoCD diffs by +# string comparison, so a non-canonical value in git will always differ from +# the live object, causing permanent OutOfSync. +# +# Rules enforced here: +# CPU integers — k8s converts integer 1 to string "1" (see PR #163) +# CPU milliCPU — k8s converts 1000m → "1", 2000m → "2", etc. (PR #164) +# Memory Mi→Gi — k8s converts 1024Mi → 1Gi, 2048Mi → 2Gi, etc. (PR #163) +# clusterIP null — k8s assigns a real IP; null in git always differs +# from the live assigned value (see PR #166) + +# ---- Container helpers ---- +# Extracts containers from Deployment/StatefulSet/DaemonSet/Job/CronJob/Pod + +_containers contains c if { c := input.spec.template.spec.containers[_] } +_containers contains c if { c := input.spec.template.spec.initContainers[_] } +_containers contains c if { c := input.spec.containers[_] } +_containers contains c if { c := input.spec.initContainers[_] } +_containers contains c if { c := input.spec.jobTemplate.spec.template.spec.containers[_] } + +# ---- CPU: must not be an integer ---- +# YAML `cpu: 1` (unquoted) parses as JSON integer; k8s stores as string "1". + +deny contains msg if { + c := _containers[_] + cpu := c.resources.limits.cpu + is_number(cpu) + msg := sprintf( + "%s container %q: cpu limit %v is an unquoted integer — use \"%v\" (string) to prevent ArgoCD OutOfSync", + [input.kind, c.name, cpu, cpu], + ) +} + +deny contains msg if { + c := _containers[_] + cpu := c.resources.requests.cpu + is_number(cpu) + msg := sprintf( + "%s container %q: cpu request %v is an unquoted integer — use \"%v\" (string) to prevent ArgoCD OutOfSync", + [input.kind, c.name, cpu, cpu], + ) +} + +deny contains msg if { + input.kind == "Cluster" + cpu := input.spec.resources.limits.cpu + is_number(cpu) + msg := sprintf( + "Cluster %s/%s: cpu limit %v is an unquoted integer — use \"%v\" (string) to prevent ArgoCD OutOfSync", + [input.metadata.namespace, input.metadata.name, cpu, cpu], + ) +} + +deny contains msg if { + input.kind == "Cluster" + cpu := input.spec.resources.requests.cpu + is_number(cpu) + msg := sprintf( + "Cluster %s/%s: cpu request %v is an unquoted integer — use \"%v\" (string) to prevent ArgoCD OutOfSync", + [input.metadata.namespace, input.metadata.name, cpu, cpu], + ) +} + +# ---- CPU: milliCPU divisible by 1000 normalizes to a whole number ---- +# k8s converts 1000m → "1", 2000m → "2". Use the canonical whole-number form. + +_milli_whole(cpu) := n if { + is_string(cpu) + endswith(cpu, "m") + val := to_number(substring(cpu, 0, count(cpu) - 1)) + val % 1000 == 0 + val > 0 + n := val / 1000 +} + +deny contains msg if { + c := _containers[_] + n := _milli_whole(c.resources.limits.cpu) + msg := sprintf( + "%s container %q: cpu limit %q normalizes to \"%v\" — use canonical form to prevent ArgoCD OutOfSync", + [input.kind, c.name, c.resources.limits.cpu, n], + ) +} + +deny contains msg if { + c := _containers[_] + n := _milli_whole(c.resources.requests.cpu) + msg := sprintf( + "%s container %q: cpu request %q normalizes to \"%v\" — use canonical form to prevent ArgoCD OutOfSync", + [input.kind, c.name, c.resources.requests.cpu, n], + ) +} + +deny contains msg if { + input.kind == "Cluster" + n := _milli_whole(input.spec.resources.limits.cpu) + msg := sprintf( + "Cluster %s/%s: cpu limit %q normalizes to \"%v\" — use canonical form to prevent ArgoCD OutOfSync", + [input.metadata.namespace, input.metadata.name, input.spec.resources.limits.cpu, n], + ) +} + +deny contains msg if { + input.kind == "Cluster" + n := _milli_whole(input.spec.resources.requests.cpu) + msg := sprintf( + "Cluster %s/%s: cpu request %q normalizes to \"%v\" — use canonical form to prevent ArgoCD OutOfSync", + [input.metadata.namespace, input.metadata.name, input.spec.resources.requests.cpu, n], + ) +} + +# ---- Memory: Mi values divisible by 1024 normalize to Gi ---- +# k8s converts 1024Mi → 1Gi, 2048Mi → 2Gi, etc. + +_mi_canonical(mem) := canonical if { + endswith(mem, "Mi") + val := to_number(substring(mem, 0, count(mem) - 2)) + val % 1024 == 0 + val > 0 + canonical := sprintf("%vGi", [val / 1024]) +} + +deny contains msg if { + c := _containers[_] + canonical := _mi_canonical(c.resources.limits.memory) + msg := sprintf( + "%s container %q: memory limit %q normalizes to %q — use canonical form to prevent ArgoCD OutOfSync", + [input.kind, c.name, c.resources.limits.memory, canonical], + ) +} + +deny contains msg if { + c := _containers[_] + canonical := _mi_canonical(c.resources.requests.memory) + msg := sprintf( + "%s container %q: memory request %q normalizes to %q — use canonical form to prevent ArgoCD OutOfSync", + [input.kind, c.name, c.resources.requests.memory, canonical], + ) +} + +deny contains msg if { + input.kind == "Cluster" + canonical := _mi_canonical(input.spec.resources.limits.memory) + msg := sprintf( + "Cluster %s/%s: memory limit %q normalizes to %q — use canonical form to prevent ArgoCD OutOfSync", + [input.metadata.namespace, input.metadata.name, input.spec.resources.limits.memory, canonical], + ) +} + +deny contains msg if { + input.kind == "Cluster" + canonical := _mi_canonical(input.spec.resources.requests.memory) + msg := sprintf( + "Cluster %s/%s: memory request %q normalizes to %q — use canonical form to prevent ArgoCD OutOfSync", + [input.metadata.namespace, input.metadata.name, input.spec.resources.requests.memory, canonical], + ) +} + +# ---- Service: clusterIP must not be null ---- +# k8s assigns a real IP on creation; clusterIP is immutable afterward. +# Setting null in git means desired=null vs live=10.x.x.x → permanent OutOfSync. +# Remove the field and let k8s own it. + +deny contains msg if { + input.kind == "Service" + input.spec.clusterIP == null + msg := sprintf( + "Service %s/%s has 'clusterIP: null' — remove this field; Kubernetes assigns the IP on creation and it causes ArgoCD OutOfSync", + [input.metadata.namespace, input.metadata.name], + ) +}