diff --git a/config/olm/template/manifests/kollect.clusterserviceversion.yaml b/config/olm/template/manifests/kollect.clusterserviceversion.yaml index 8036686a..5b5ded40 100644 --- a/config/olm/template/manifests/kollect.clusterserviceversion.yaml +++ b/config/olm/template/manifests/kollect.clusterserviceversion.yaml @@ -45,6 +45,229 @@ metadata: }, "suspend": false } + }, + { + "apiVersion": "kollect.dev/v1alpha1", + "kind": "KollectInventory", + "metadata": { + "name": "team-inventory", + "namespace": "default" + }, + "spec": { + "exportMinInterval": "30s", + "snapshotSinkRefs": [ + { + "name": "git-inventory", + "exportMinInterval": "1h" + } + ], + "databaseSinkRefs": [ + { + "name": "postgres-inventory" + } + ], + "suspend": false + } + }, + { + "apiVersion": "kollect.dev/v1alpha1", + "kind": "KollectSnapshotSink", + "metadata": { + "name": "git-inventory", + "namespace": "default" + }, + "spec": { + "type": "git", + "endpoint": "https://github.com/platformrelay/kollect-inventory-demo.git", + "cluster": "prod-west", + "pathTemplate": "clusters/{cluster}/inventory/{namespace}/{name}.json", + "connectionTest": true, + "secretRef": { + "name": "git-push-credentials", + "namespace": "default" + }, + "git": { + "branch": "main", + "pushPolicy": "Commit", + "auth": { + "type": "token" + } + } + } + }, + { + "apiVersion": "kollect.dev/v1alpha1", + "kind": "KollectEventSink", + "metadata": { + "name": "nats-inventory", + "namespace": "default" + }, + "spec": { + "type": "nats", + "cluster": "prod-west", + "connectionTest": true, + "secretRef": { + "name": "nats-credentials", + "namespace": "default" + }, + "nats": { + "url": "nats://nats.kollect-system.svc:4222", + "subject": "inventory.events", + "stream": "kollect_events" + } + } + }, + { + "apiVersion": "kollect.dev/v1alpha1", + "kind": "KollectDatabaseSink", + "metadata": { + "name": "postgres-inventory", + "namespace": "default" + }, + "spec": { + "type": "postgres", + "cluster": "prod-west", + "connectionTest": true, + "provisioning": { + "mode": "ensure" + }, + "postgres": { + "databaseRef": { + "name": "inventory-postgres-dsn", + "namespace": "default" + }, + "schema": "public", + "table": "inventory_items" + } + } + }, + { + "apiVersion": "kollect.dev/v1alpha1", + "kind": "KollectConnectionTest", + "metadata": { + "name": "postgres-inventory-probe", + "namespace": "default" + }, + "spec": { + "sinkRef": { + "databaseSinkRef": "postgres-inventory" + }, + "ttlSecondsAfterFinished": 300 + } + }, + { + "apiVersion": "kollect.dev/v1alpha1", + "kind": "KollectScope", + "metadata": { + "name": "team-ceiling", + "namespace": "default" + }, + "spec": { + "allowedGVKs": [ + { + "group": "apps", + "version": "v1", + "kind": "Deployment" + }, + { + "group": "", + "version": "v1", + "kind": "Service" + } + ], + "allowedNamespaces": [ + "default" + ], + "snapshotSinkRefs": [ + "git-inventory" + ], + "databaseSinkRefs": [ + "postgres-inventory" + ], + "minExportInterval": "30s" + } + }, + { + "apiVersion": "kollect.dev/v1alpha1", + "kind": "KollectClusterTarget", + "metadata": { + "name": "platform-deployments" + }, + "spec": { + "profileRef": { + "name": "deployment-images", + "namespace": "default" + }, + "namespaceSelector": { + "matchLabels": { + "kollect.dev/tenant": "platform" + } + }, + "suspend": false + } + }, + { + "apiVersion": "kollect.dev/v1alpha1", + "kind": "KollectClusterInventory", + "metadata": { + "name": "platform-rollup" + }, + "spec": { + "targetRefs": [ + "platform-deployments" + ], + "namespaceSelector": { + "matchLabels": { + "kollect.dev/tenant": "platform" + } + }, + "sinkNamespace": "default", + "snapshotSinkRefs": [ + { + "name": "git-inventory" + } + ], + "databaseSinkRefs": [ + { + "name": "postgres-inventory" + } + ], + "suspend": false + } + }, + { + "apiVersion": "kollect.dev/v1alpha1", + "kind": "KollectClusterScope", + "metadata": { + "name": "platform-ceiling" + }, + "spec": { + "allowedGVKs": [ + { + "group": "apps", + "version": "v1", + "kind": "Deployment" + }, + { + "group": "", + "version": "v1", + "kind": "Service" + } + ], + "deniedNamespaces": [ + "kube-system" + ], + "allowedStaticRefNamespaces": [ + "default" + ], + "snapshotSinkRefs": [ + "git-inventory" + ], + "databaseSinkRefs": [ + "postgres-inventory" + ], + "minExportInterval": "30s" + } } ] categories: Monitoring,Integration & Delivery diff --git a/hack/test/dist_olm_bundle_test.sh b/hack/test/dist_olm_bundle_test.sh index cc4801b8..a0fd1c85 100755 --- a/hack/test/dist_olm_bundle_test.sh +++ b/hack/test/dist_olm_bundle_test.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash # DIST-OLM-01: generate-olm-bundle must emit a registry+v1 bundle with all owned CRDs. +# DIST-OH-01: every owned CRD must carry an alm-examples entry. set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" @@ -17,6 +18,14 @@ pass() { TEMPLATE="${ROOT}/config/olm/template/manifests/kollect.clusterserviceversion.yaml" [[ -f "${TEMPLATE}" ]] || fail "${TEMPLATE} is missing" +# yq drives the CSV/role.yaml RBAC drift gate, jq the alm-examples coverage gate. +# Both are hard requirements: without them there is nothing to compare and the +# gates below would be skipped rather than enforced. +command -v yq >/dev/null 2>&1 || + fail "yq (mikefarah/yq v4) is required for the CSV/role.yaml RBAC drift gate" +command -v jq >/dev/null 2>&1 || + fail "jq is required for the CSV alm-examples coverage gate" + grep -Fq 'make generate-olm-bundle' "${ROOT}/Makefile" || fail "Makefile must define generate-olm-bundle target" @@ -42,16 +51,64 @@ csv="${BUNDLE_DIR}/manifests/kollect.clusterserviceversion.yaml" grep -Fq "kollect.v${VERSION}" "${csv}" || fail "CSV metadata.name must include version" grep -Fq "ghcr.io/platformrelay/kollect@${IMAGE_DIGEST}" "${csv}" || fail "CSV must digest-pin the controller image" -grep -Fq 'kind: KollectProfile' "${csv}" || fail "CSV alm-examples must include KollectProfile" -grep -Fq 'kind: KollectTarget' "${csv}" || fail "CSV alm-examples must include KollectTarget" + +# alm-examples coverage gate (DIST-OH-01). The OpenShift console and operatorhub.io +# pre-fill "Create instance" from metadata.annotations["alm-examples"]; an owned CRD +# with no entry hands the user a blank YAML editor, and the OperatorHub hosted +# validator warns "provided API should have an example annotation". Comparing the two +# sets is what makes a newly owned CRD unable to ship without an example -- the two +# `grep -Fq 'kind: Kollect...'` checks this replaces matched the *owned* list (plain +# YAML), never the JSON annotation, so they never tested alm-examples at all. +# +# Both the template (the file an author edits) and the generated bundle CSV (what +# upstream actually validates) are checked: generation is a sed pass, so a +# template-only assertion would not prove the annotation survives it. +check_alm_examples() { + local manifest="$1" label="$2" + local examples example_kinds owned_kinds example_count unique_count + + examples="$(yq '.metadata.annotations."alm-examples"' "${manifest}")" + [[ -n "${examples}" && "${examples}" != "null" ]] || + fail "${label}: metadata.annotations[\"alm-examples\"] is missing or empty" + + printf '%s' "${examples}" | jq empty || + fail "${label}: alm-examples is not valid JSON" + printf '%s' "${examples}" | jq -e 'type == "array"' >/dev/null || + fail "${label}: alm-examples must be a JSON array" + + example_kinds="$(printf '%s' "${examples}" | jq -r '.[].kind' | LC_ALL=C sort)" + owned_kinds="$(yq '.spec.customresourcedefinitions.owned[].kind' "${manifest}" | LC_ALL=C sort)" + + # Both extractions must be non-empty, otherwise a mistyped yq/jq path would make + # the comparison below pass vacuously and the gate would never fire. + [[ -n "${example_kinds}" ]] || + fail "${label}: extracted no kinds from alm-examples -- the coverage gate would pass vacuously" + [[ -n "${owned_kinds}" ]] || + fail "${label}: extracted no kinds from spec.customresourcedefinitions.owned -- the coverage gate would pass vacuously" + + # One example per owned CRD: a duplicated kind must not mask a missing one. + example_count="$(printf '%s\n' "${example_kinds}" | wc -l | tr -d ' ')" + unique_count="$(printf '%s\n' "${example_kinds}" | LC_ALL=C sort -u | wc -l | tr -d ' ')" + [[ "${example_count}" == "${unique_count}" ]] || + fail "${label}: alm-examples has duplicate kinds (${example_count} entries, ${unique_count} distinct)" + + if [[ "${example_kinds}" != "${owned_kinds}" ]]; then + printf 'dist olm bundle: %s alm-examples do not cover spec.customresourcedefinitions.owned ("<" alm-examples, ">" owned):\n' \ + "${label}" >&2 + diff <(printf '%s\n' "${example_kinds}") <(printf '%s\n' "${owned_kinds}") >&2 || true + fail "${label}: add one alm-examples entry per owned CRD in config/olm/template/manifests/kollect.clusterserviceversion.yaml" + fi + + pass "${label}: alm-examples covers all ${example_count} owned CRDs" +} + +check_alm_examples "${TEMPLATE}" "CSV template" +check_alm_examples "${csv}" "generated CSV" # RBAC drift gate: the CSV clusterPermissions are a hand copy of the # controller-gen-generated config/rbac/role.yaml. Without this gate the next # +kubebuilder:rbac marker change would silently ship an under-privileged bundle # that 403s at runtime on OperatorHub while the Helm chart keeps working. -command -v yq >/dev/null 2>&1 || - fail "yq (mikefarah/yq v4) is required for the CSV/role.yaml RBAC drift gate" - ROLE="${ROOT}/config/rbac/role.yaml" [[ -f "${ROLE}" ]] || fail "${ROLE} is missing"