diff --git a/.gitignore b/.gitignore index c179a99d9..e4a4c7349 100644 --- a/.gitignore +++ b/.gitignore @@ -61,6 +61,12 @@ demo/ # the overlay root without a `../../` parent traversal (LoadRestrictionsRootOnly). deploy/kind/prometheus/keystone-operator.json +# Build artifact: staged at deploy time by hack/dizzy.sh stage-dashboards from +# the pinned dizzy release tarball (WITH_DIZZY=true) so the dizzy overlay's +# configMapGenerator can reference the dashboard JSONs from the overlay root +# without a `../../` parent traversal (LoadRestrictionsRootOnly). +deploy/kind/dizzy/dashboards/ + # Vendored Helm subchart dependencies. The operator charts depend on the # operator-library library chart via a file:// path; `helm dependency build` # vendors it into charts/ from that local source. Chart.lock is committed; the diff --git a/Makefile b/Makefile index 68c4328f4..929c21f95 100644 --- a/Makefile +++ b/Makefile @@ -594,6 +594,31 @@ stage-prometheus-dashboard: deploy-infra: hack/deploy-infra.sh +.PHONY: dizzy-keystone dizzy-glance +# dizzy-keystone and dizzy-glance run dizzy's chaos churn soak (a 5-minute +# small profile by default) against the quick-start ControlPlane, exporting +# metrics to the dizzy VictoriaMetrics; watch them at +# https://dizzy.127-0-0-1.nip.io. +# +# Variables: +# DIZZY_SCENARIO alternate scenario file. +# DIZZY_ARGS extra dizzy flags, space-separated (e.g. --duration 30m). +# DIZZY_VERSION pin override; the pin itself lives only in hack/dizzy.sh +# so Renovate has exactly one string to bump. +# DIZZY_SECRET ControlPlane admin Secret name override. +# DIZZY_CP_NAMESPACE ControlPlane admin Secret namespace override. +# +# The three preflights are kept separate so the failure modes stay +# distinguishable — the kubectl/cluster-reachability failure is not conflated +# with the dizzy-stack-not-installed failure nor with the missing +# ControlPlane-admin-Secret failure — see review pattern +# .planwerk/review_patterns/distinguish-collapsed-failure-modes-in-preflight-checks.md +dizzy-keystone dizzy-glance: dizzy-%: + @kubectl version --request-timeout=2s >/dev/null 2>&1 || { echo 'kubectl is not configured or no cluster is reachable' >&2; exit 1; } + @kubectl get ns dizzy >/dev/null 2>&1 || { echo 'the dizzy stack is not installed; run `WITH_DIZZY=true make deploy-infra` first' >&2; exit 1; } + @kubectl get secret "$${DIZZY_SECRET:-controlplane-keystone-admin-credentials}" -n "$${DIZZY_CP_NAMESPACE:-openstack}" >/dev/null 2>&1 || { echo 'no ControlPlane admin Secret found; deploy the quick-start ControlPlane and wait for Ready first' >&2; exit 1; } + hack/dizzy.sh chaos $* + .PHONY: refresh-operator-digests # refresh-operator-digests re-resolves the digest behind the self-built # operators' :latest images (keystone/c5c3/horizon), refreshes the diff --git a/deploy/kind/base/openstack-gateway.yaml b/deploy/kind/base/openstack-gateway.yaml index 97e5f50eb..3170b8247 100644 --- a/deploy/kind/base/openstack-gateway.yaml +++ b/deploy/kind/base/openstack-gateway.yaml @@ -3,9 +3,10 @@ # SPDX-License-Identifier: Apache-2.0 # Gateway API wiring for the kind Quick Start — `GatewayClass/envoy` -# plus a three-listener HTTPS Gateway on port 443 -# (`keystone.127-0-0-1.nip.io`, `horizon.127-0-0-1.nip.io`, and -# `glance.127-0-0-1.nip.io`, routed by SNI) terminating TLS with +# plus a four-listener HTTPS Gateway on port 443 +# (`keystone.127-0-0-1.nip.io`, `horizon.127-0-0-1.nip.io`, +# `glance.127-0-0-1.nip.io`, and `dizzy.127-0-0-1.nip.io`, routed by +# SNI) terminating TLS with # self-signed certificates issued by the existing `selfsigned-cluster-issuer` # ClusterIssuer (the same issuer OpenBao uses; see # `deploy/flux-system/infrastructure/cluster-issuer.yaml`). @@ -109,3 +110,37 @@ spec: allowedRoutes: namespaces: from: Same + # Fourth HTTPS listener for the Grafana UI of the opt-in dizzy + # observability overlay (`deploy/kind/dizzy/`, gated on + # WITH_DIZZY=true in hack/deploy-infra.sh). It shares port 443 with + # the listeners above, routed by SNI on its distinct `hostname` and + # terminating with its own certificate; the envoy-nodeport + # StrategicMerge patch keys by `port: 443` and is untouched by + # another listener on the same port. + # + # This is the repo's FIRST listener admitting routes from another + # namespace (`allowedRoutes.namespaces.from: Selector` instead of + # `Same`): the static `dizzy-grafana` HTTPRoute lives next to its + # backend in the gated `dizzy` overlay's namespace, selected via the + # automatic `kubernetes.io/metadata.name` namespace label (no custom + # label needed) — this avoids introducing the repo's first + # ReferenceGrant. + # + # The listener is unconditional: with WITH_DIZZY unset no route + # attaches and requests for this SNI get 404, mirroring how the + # glance listener exists without a Glance service. + - name: https-dizzy + protocol: HTTPS + port: 443 + hostname: dizzy.127-0-0-1.nip.io + tls: + mode: Terminate + certificateRefs: + - kind: Secret + name: dizzy-nip-io-tls + allowedRoutes: + namespaces: + from: Selector + selector: + matchLabels: + kubernetes.io/metadata.name: dizzy diff --git a/deploy/kind/dizzy/httproute.yaml b/deploy/kind/dizzy/httproute.yaml new file mode 100644 index 000000000..0a05a46bf --- /dev/null +++ b/deploy/kind/dizzy/httproute.yaml @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: Copyright 2026 SAP SE or an SAP affiliate company +# +# SPDX-License-Identifier: Apache-2.0 + +# Static HTTPRoute exposing the dizzy Grafana UI at +# https://dizzy.127-0-0-1.nip.io — the repo's FIRST hand-authored +# HTTPRoute. Every other HTTPRoute in the tree is projected by a service +# operator (keystone/horizon/glance); this one lives next to its backend +# in the gated dizzy overlay because there is no operator projecting it. +# +# It attaches to the `https-dizzy` listener on Gateway `openstack-gw` +# (namespace `openstack`). That listener admits cross-namespace routes via +# `allowedRoutes.namespaces.from: Selector` matching the automatic +# `kubernetes.io/metadata.name: dizzy` label — so NO ReferenceGrant is +# needed: route→Gateway attachment is governed by the listener's +# allowedRoutes, and the backend Service (dizzy-grafana) is same-namespace. +# +# The backendRef targets the grafana chart's Service (port 80 → container +# 3000). +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: dizzy-grafana + namespace: dizzy +spec: + parentRefs: + - name: openstack-gw + namespace: openstack + sectionName: https-dizzy + hostnames: + - dizzy.127-0-0-1.nip.io + rules: + - backendRefs: + - name: dizzy-grafana + port: 80 diff --git a/deploy/kind/dizzy/kustomization.yaml b/deploy/kind/dizzy/kustomization.yaml new file mode 100644 index 000000000..e89724a9e --- /dev/null +++ b/deploy/kind/dizzy/kustomization.yaml @@ -0,0 +1,76 @@ +# SPDX-FileCopyrightText: Copyright 2026 SAP SE or an SAP affiliate company +# +# SPDX-License-Identifier: Apache-2.0 + +# Opt-in kind overlay for the dizzy observability stack — applied only when +# the operator explicitly requests it (WITH_DIZZY=true make deploy-infra). +# +# Bundles everything needed to render a working VictoriaMetrics + Grafana +# stack on a kind cluster: +# 1. The dizzy Namespace (declared inline so the overlay is +# self-contained; unlike the prometheus overlay's Namespace it lives +# ONLY here — production ships no dizzy stack — see namespace.yaml). +# 2. Two HelmRepository sources declared into `flux-system` +# (victoria-metrics, grafana) so deploy/flux-system/** stays untouched. +# 3. Two HelmReleases (VictoriaMetrics-single, Grafana) with kind-tuned +# values baked directly into the release files. +# 4. The static `dizzy-grafana` HTTPRoute attaching to the `https-dizzy` +# Gateway listener. +# 5. A configMapGenerator-produced ConfigMap wrapping the three dizzy +# dashboard JSONs for the Grafana dashboard provider. +# +# All resource references are local to this overlay (no parent-directory +# `../../` paths). The overlay therefore renders cleanly under kustomize's +# default `LoadRestrictionsRootOnly` security check, which means +# `kubectl apply -k deploy/kind/dizzy` works without `--load-restrictor`. +# kubectl's embedded kustomize does NOT expose `--load-restrictor` +# (kubernetes/kubectl#948), so keeping the overlay self-contained is the +# only way to ship `kubectl apply -k` as the production caller in +# hack/deploy-infra.sh — same constraint that drove the chaos-mesh and +# prometheus overlays. +# +# STAGING CONTRACT the dashboards/ directory is git-ignored (see +# .gitignore) and does NOT exist in a fresh checkout. hack/dizzy.sh +# stage-dashboards populates it from the pinned dizzy release tarball +# BEFORE `kustomize build` can resolve the configMapGenerator's `files:` +# below (LoadRestrictionsRootOnly forbids reaching outside the overlay for +# them). `WITH_DIZZY=true make deploy-infra` runs the staging +# automatically; a raw `kustomize build deploy/kind/dizzy` on a fresh +# checkout FAILS until stage-dashboards has run. +--- +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +# NO top-level `namespace:` on purpose: the two HelmRepository sources live +# in `flux-system` while everything else lives in `dizzy`. A global +# namespace would clobber the HelmRepositories into `dizzy` and break the +# HelmRelease sourceRefs, so each resource carries its own namespace. + +resources: + - namespace.yaml + - source-victoria-metrics.yaml + - source-grafana.yaml + - release-victoria-metrics.yaml + - release-grafana.yaml + - httproute.yaml + +configMapGenerator: + # Wraps the three dizzy dashboard JSONs so the Grafana dashboard provider + # (dashboardsConfigMaps.dizzy in release-grafana.yaml) mounts them. The + # files below are staged at deploy time by hack/dizzy.sh stage-dashboards + # and are NOT tracked in git (see .gitignore); the canonical source is the + # pinned dizzy release tarball. + - name: grafana-dashboards + namespace: dizzy + files: + - dashboards/overview.json + - dashboards/api-operations.json + - dashboards/time-to-ready.json + options: + # disableNameSuffixHash keeps the ConfigMap name stable + # (`grafana-dashboards`, no `-` suffix) because the grafana + # chart references it by fixed name via dashboardsConfigMaps. The + # trade-off is that Grafana does NOT auto-reload on a dashboard edit + # — acceptable here since the dashboards are a version-pinned asset + # that only changes on a dizzy bump + redeploy. + disableNameSuffixHash: true diff --git a/deploy/kind/dizzy/namespace.yaml b/deploy/kind/dizzy/namespace.yaml new file mode 100644 index 000000000..65205f333 --- /dev/null +++ b/deploy/kind/dizzy/namespace.yaml @@ -0,0 +1,35 @@ +# SPDX-FileCopyrightText: Copyright 2026 SAP SE or an SAP affiliate company +# +# SPDX-License-Identifier: Apache-2.0 + +# kind-only opt-in Namespace for the dizzy observability stack +# (VictoriaMetrics + Grafana). +# +# DECISION Unlike the monitoring Namespace of the prometheus overlay, this +# Namespace is declared ONLY here and must NOT be added to +# deploy/flux-system/namespaces.yaml: production ships no dizzy stack at +# all, so a `dizzy` Namespace under the production flux-system bundle would +# be dead weight. The inline copy in this overlay is kept because: +# 1. The overlay is applied STANDALONE via `kubectl apply -k +# deploy/kind/dizzy` (gated on WITH_DIZZY=true in +# hack/deploy-infra.sh); it is NOT a child of deploy/kind/base, so +# there is no parent overlay guaranteed to create the Namespace +# first. +# 2. kubectl's embedded kustomize cannot pass --load-restrictor +# (kubernetes/kubectl#948), so the overlay MUST be self-contained +# under the default LoadRestrictionsRootOnly check — a `../../` +# reference into deploy/flux-system/namespaces.yaml would refuse to +# render (same constraint that drove the chaos-mesh and prometheus +# overlay layouts). +# 3. `kubectl apply -k` is idempotent for Namespace resources, so a +# re-apply is a no-op rather than a conflict. +# +# The Namespace name doubles as the route-admission key: the +# `https-dizzy` Gateway listener admits HTTPRoutes from namespaces +# labelled `kubernetes.io/metadata.name: dizzy` (the automatic label +# every Namespace carries), so no custom label is needed here. +--- +apiVersion: v1 +kind: Namespace +metadata: + name: dizzy diff --git a/deploy/kind/dizzy/release-grafana.yaml b/deploy/kind/dizzy/release-grafana.yaml new file mode 100644 index 000000000..dbe9432ad --- /dev/null +++ b/deploy/kind/dizzy/release-grafana.yaml @@ -0,0 +1,92 @@ +# SPDX-FileCopyrightText: Copyright 2026 SAP SE or an SAP affiliate company +# +# SPDX-License-Identifier: Apache-2.0 + +# Grafana HelmRelease — anonymous read-only dashboard frontend for the +# dizzy observability stack, reached at https://dizzy.127-0-0-1.nip.io via +# the static HTTPRoute in httproute.yaml. Opt-in: reconciled only when the +# dizzy overlay is applied (WITH_DIZZY=true make deploy-infra). +# +# SERVICE NAME the grafana chart's fullname helper collapses to the bare +# release name when that name already contains "grafana", so release +# `dizzy-grafana` renders Service `dizzy-grafana` (port 80 → container +# 3000) — the name httproute.yaml's backendRef targets. +# +# VALUES LAYOUT VERIFIED against grafana 10.5.15 with `helm show values` +# and `helm template`: the `grafana.ini`, `datasources`, +# `dashboardProviders`, and `dashboardsConfigMaps` top-level keys exist +# with these shapes, the Service defaults to port 80, and the rendered +# Service is `dizzy-grafana`. No corrections were needed. +--- +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: dizzy-grafana + namespace: dizzy +spec: + interval: 30m + chart: + spec: + chart: grafana + version: ">=10.0.0 <11.0.0" + sourceRef: + kind: HelmRepository + name: grafana + namespace: flux-system + install: + createNamespace: true + remediation: + retries: 3 + upgrade: + remediation: + retries: 3 + values: + grafana.ini: + auth.anonymous: + # Anonymous Viewer access — kind-only localhost posture: the stack + # is reachable solely via the host's :443 bridge, so a login form + # would only get in the way of the local demo. + enabled: true + org_role: Viewer + auth: + disable_login_form: true + analytics: + reporting_enabled: false + dashboards: + default_home_dashboard_path: /var/lib/grafana/dashboards/dizzy/overview.json + datasources: + datasources.yaml: + apiVersion: 1 + datasources: + - name: VictoriaMetrics + type: prometheus + # The three provisioned dashboards reference this datasource by + # the fixed uid `victoriametrics`, so it must stay verbatim. + uid: victoriametrics + access: proxy + # Matches the VictoriaMetrics server Service name from + # release-victoria-metrics.yaml. + url: http://dizzy-victoria-metrics-server.dizzy.svc:8428 + isDefault: true + jsonData: + # Mirrors OTEL_METRIC_EXPORT_INTERVAL=15000 pinned in + # hack/dizzy.sh, so the graph step matches the ingest cadence. + timeInterval: "15s" + dashboardProviders: + dashboardproviders.yaml: + apiVersion: 1 + providers: + - name: dizzy + folder: "" + type: file + disableDeletion: false + options: + path: /var/lib/grafana/dashboards/dizzy + dashboardsConfigMaps: + dizzy: grafana-dashboards + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + memory: 256Mi diff --git a/deploy/kind/dizzy/release-victoria-metrics.yaml b/deploy/kind/dizzy/release-victoria-metrics.yaml new file mode 100644 index 000000000..b9569cff9 --- /dev/null +++ b/deploy/kind/dizzy/release-victoria-metrics.yaml @@ -0,0 +1,80 @@ +# SPDX-FileCopyrightText: Copyright 2026 SAP SE or an SAP affiliate company +# +# SPDX-License-Identifier: Apache-2.0 + +# VictoriaMetrics-single HelmRelease — the OTLP metrics backend the dizzy +# load/chaos tester exports into (NodePort 30428, bridged to the host's +# TCP :8428 by hack/kind-config.yaml). Opt-in: reconciled only when the +# dizzy overlay is applied (WITH_DIZZY=true make deploy-infra). +# +# SERVICE NAME the victoria-metrics-single chart appends `-server` to the +# fullname, so with `fullnameOverride: dizzy-victoria-metrics` (top-level, +# NOT under `server:` — see below) the rendered server Service is +# `dizzy-victoria-metrics-server`. The Grafana datasource URL in +# release-grafana.yaml +# (http://dizzy-victoria-metrics-server.dizzy.svc:8428) MUST match this +# name; verify with `kubectl get svc -n dizzy` after the first deploy. +# +# VALUES LAYOUT VERIFIED against victoria-metrics-single 0.43.0 with +# `helm template`: the server Service renders as +# `dizzy-victoria-metrics-server` (type NodePort, port 8428, nodePort +# 30428), the extraArgs land as container args +# (--opentelemetry.usePrometheusNaming, --opentelemetry.promoteAllResource +# Attributes), and storage is an emptyDir. Correction from the initial +# draft: `fullnameOverride` belongs at the TOP level, not under `server:` — +# placed under `server:` the chart drops the `-server` suffix and renders +# `dizzy-victoria-metrics`, which would break the datasource URL. +--- +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: dizzy-victoria-metrics + namespace: dizzy +spec: + interval: 30m + chart: + spec: + chart: victoria-metrics-single + # Pin to a single 0.x minor: SemVer permits breaking changes on every + # 0.x minor bump, and this chart's fullname/extraArgs/nodePort shape is + # what the hard-coded Grafana datasource URL and the container args + # depend on (VERIFIED against 0.43.0, see the header note). A wider + # range (e.g. `<1.0.0`) would let Flux auto-pull a breaking minor and + # silently render the server Service under a different name. + version: ">=0.43.0 <0.44.0" + sourceRef: + kind: HelmRepository + name: victoria-metrics + namespace: flux-system + install: + createNamespace: true + remediation: + retries: 3 + upgrade: + remediation: + retries: 3 + values: + # Top-level so the chart's fullname helper renders the server Service + # as `dizzy-victoria-metrics-server` (adding fullnameOverride under + # `server:` drops the `-server` suffix — see the header note). + fullnameOverride: dizzy-victoria-metrics + server: + replicaCount: 1 + persistentVolume: + # emptyDir storage with a 30d retention window: kind-only, + # throwaway posture — metrics evaporate when the pod restarts, + # which is fine for a local load/chaos soak. + enabled: false + retentionPeriod: 30d + extraArgs: + # Render OTLP metrics with Prometheus-style names so the + # provisioned dashboards' PromQL matches (flag since + # VictoriaMetrics v1.145.0). + opentelemetry.usePrometheusNaming: "true" + # Promote every OTEL resource attribute to a label so the + # dashboards can group by the dizzy `cloud`/`scenario`/`service` + # attributes (flag since VictoriaMetrics v1.145.0). + opentelemetry.promoteAllResourceAttributes: "true" + service: + type: NodePort + nodePort: 30428 diff --git a/deploy/kind/dizzy/source-grafana.yaml b/deploy/kind/dizzy/source-grafana.yaml new file mode 100644 index 000000000..03fd31b64 --- /dev/null +++ b/deploy/kind/dizzy/source-grafana.yaml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: Copyright 2026 SAP SE or an SAP affiliate company +# +# SPDX-License-Identifier: Apache-2.0 + +# Grafana HelmRepository — chart source for the dizzy overlay's dashboard +# frontend. Declared into the `flux-system` namespace (where Flux resolves +# HelmRelease sourceRefs) even though this file lives inside the kind-only +# dizzy overlay, so deploy/flux-system/** stays untouched and the overlay +# remains self-contained (no `../../` parent traversal — same +# LoadRestrictionsRootOnly constraint that drove the chaos-mesh source). +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: HelmRepository +metadata: + name: grafana + namespace: flux-system +spec: + interval: 1h + url: https://grafana.github.io/helm-charts diff --git a/deploy/kind/dizzy/source-victoria-metrics.yaml b/deploy/kind/dizzy/source-victoria-metrics.yaml new file mode 100644 index 000000000..891c95704 --- /dev/null +++ b/deploy/kind/dizzy/source-victoria-metrics.yaml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: Copyright 2026 SAP SE or an SAP affiliate company +# +# SPDX-License-Identifier: Apache-2.0 + +# VictoriaMetrics HelmRepository — chart source for the dizzy overlay's +# OTLP metrics backend. Declared into the `flux-system` namespace (where +# Flux resolves HelmRelease sourceRefs) even though this file lives inside +# the kind-only dizzy overlay, so deploy/flux-system/** stays untouched and +# the overlay remains self-contained (no `../../` parent traversal — same +# LoadRestrictionsRootOnly constraint that drove the chaos-mesh source). +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: HelmRepository +metadata: + name: victoria-metrics + namespace: flux-system +spec: + interval: 1h + url: https://victoriametrics.github.io/helm-charts diff --git a/deploy/kind/infrastructure/dizzy-nip-io-tls-certificate.yaml b/deploy/kind/infrastructure/dizzy-nip-io-tls-certificate.yaml new file mode 100644 index 000000000..da0e003a9 --- /dev/null +++ b/deploy/kind/infrastructure/dizzy-nip-io-tls-certificate.yaml @@ -0,0 +1,43 @@ +# SPDX-FileCopyrightText: Copyright 2026 SAP SE or an SAP affiliate company +# +# SPDX-License-Identifier: Apache-2.0 + +# TLS material for the dizzy listener (`https-dizzy`) defined in +# deploy/kind/base/openstack-gateway.yaml. cert-manager issues a +# self-signed certificate for `dizzy.127-0-0-1.nip.io` and writes it +# to the `dizzy-nip-io-tls` Secret in the `openstack` namespace, +# where the Gateway's `https-dizzy` listener `certificateRefs` picks +# it up. Kept as a sibling of `keystone-nip-io-tls-certificate.yaml`, +# `horizon-nip-io-tls-certificate.yaml`, and +# `glance-nip-io-tls-certificate.yaml` (one Certificate per listener +# hostname) rather than a shared SAN certificate so each listener +# terminates with its own key material. +# +# Lives in the infrastructure overlay (Phase 2 of hack/deploy-infra.sh) +# rather than the base overlay because the `cert-manager.io/v1` +# Certificate CRD is installed by the `cert-manager` HelmRelease in +# Phase 1; applying this resource before Phase 4 ("wait HelmReleases +# Ready") would fail with `no matches for kind "Certificate"`. +# The same reasoning applies to the `selfsigned-cluster-issuer` +# ClusterIssuer it references (deploy/flux-system/infrastructure/ +# cluster-issuer.yaml) — also a Phase-2 resource. +# +# Self-signed is correct for a kind quick-start cluster (single-user +# localhost-only), matching the sibling keystone/horizon/glance +# certificates. +# +--- +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: dizzy-nip-io-tls + namespace: openstack +spec: + secretName: dizzy-nip-io-tls + duration: 8760h + renewBefore: 720h + issuerRef: + name: selfsigned-cluster-issuer + kind: ClusterIssuer + dnsNames: + - dizzy.127-0-0-1.nip.io diff --git a/deploy/kind/infrastructure/kustomization.yaml b/deploy/kind/infrastructure/kustomization.yaml index 5db89a5a6..ef54d1ecc 100644 --- a/deploy/kind/infrastructure/kustomization.yaml +++ b/deploy/kind/infrastructure/kustomization.yaml @@ -23,6 +23,9 @@ resources: # TLS for the third Gateway listener the ControlPlane Quick Start's Glance # image API is exposed through (deploy/kind/base/openstack-gateway.yaml). - glance-nip-io-tls-certificate.yaml + # TLS for the fourth Gateway listener (`https-dizzy`) the opt-in dizzy + # overlay's Grafana UI is exposed through (deploy/kind/base/openstack-gateway.yaml). + - dizzy-nip-io-tls-certificate.yaml # kind-only: standalone Keystone CRs (Quick Start, keystone e2e/tempest/ # chaos suites) reference the Secret "keystone-db"; ControlPlane-based # deployments use the operator-projected per-ControlPlane ExternalSecret diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index c6afca4e2..7d27544fc 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -126,6 +126,7 @@ export default defineConfig({ { text: 'ControlPlane E2E Tests', link: '/reference/testing/controlplane-e2e-tests' }, { text: 'Operator Upgrade E2E Tests', link: '/reference/testing/operator-upgrade-e2e-tests' }, { text: 'Chaos E2E Tests', link: '/reference/testing/chaos-e2e-tests' }, + { text: 'dizzy Chaos Testing', link: '/reference/testing/dizzy-chaos-testing' }, { text: 'Tempest Test Infrastructure', link: '/reference/testing/tempest-test-infrastructure' }, { text: 'Reconcile Performance Benchmark', link: '/reference/testing/reconcile-performance-benchmark' }, ], diff --git a/docs/quick-start-extended.md b/docs/quick-start-extended.md index 85269fdd5..1aea5125a 100644 --- a/docs/quick-start-extended.md +++ b/docs/quick-start-extended.md @@ -159,6 +159,14 @@ differs from `443`). The Chainsaw E2E suites under override** — use the [`kubectl port-forward` fallback](#fallback-kubectl-port-forward) for those suites or run them in CI (Linux + rootful Docker). + +The same kind config also binds host loopback port `8428` (mapped to NodePort +`30428`) unconditionally, so the opt-in dizzy load/chaos stack +(`WITH_DIZZY=true`) can be enabled later without recreating the cluster. Port +`8428` must therefore be free even for a plain `make deploy-infra`; if another +process already holds it — a local VictoriaMetrics binds `8428` by default — +`kind create cluster` fails with `port is already allocated`. Free the port (or +stop that process) before creating the cluster. ::: --- @@ -242,6 +250,8 @@ monitoring kube-prometheus-stack-prometheus-* Ready (kind-only; WIT monitoring kube-prometheus-stack-grafana-* Ready (kind-only; WITH_PROMETHEUS=true; see Step 4c) monitoring kube-prometheus-stack-operator-* Ready (kind-only; WITH_PROMETHEUS=true; see Step 4c) kube-system metrics-server-* Ready (kind-only; WITH_METRICS_SERVER=true) +dizzy dizzy-victoria-metrics-server-* Ready (kind-only; WITH_DIZZY=true) +dizzy dizzy-grafana-* Ready (kind-only; WITH_DIZZY=true) ``` Headlamp is deployed asynchronously and is **not** part of the `deploy-infra` wait list — a @@ -339,6 +349,23 @@ without it the generated HorizontalPodAutoscaler reports `unknown/80%` and never scales. ::: +::: tip Enabling the dizzy load/chaos stack +The dizzy load/chaos stack is **not installed by default** in the kind Quick +Start, and the production overlays (`deploy/flux-system/`) ship none of it. +Opt in by setting `WITH_DIZZY=true` before `make deploy-infra`: + +```bash +WITH_DIZZY=true make deploy-infra +``` + +This applies the kind-only overlay at `deploy/kind/dizzy/` and waits for the +`dizzy-victoria-metrics` and `dizzy-grafana` HelmReleases to become Ready. +Grafana serves the dizzy dashboards at `https://dizzy.127-0-0-1.nip.io`; append +`:` when that override is set to something other than 443. To +drive a soak, run the `make dizzy-keystone` or `make dizzy-glance` targets +documented in [dizzy Chaos Testing](./reference/testing/dizzy-chaos-testing.md). +::: + ::: tip Enabling the local registry pull-through cache Every **fresh** kind cluster pulls every third-party image (from `docker.io`, `ghcr.io`, `registry.k8s.io`, `quay.io`, and the per-project vanity registries diff --git a/docs/reference/infrastructure/infrastructure-manifests.md b/docs/reference/infrastructure/infrastructure-manifests.md index 530f10b3f..e807f5270 100644 --- a/docs/reference/infrastructure/infrastructure-manifests.md +++ b/docs/reference/infrastructure/infrastructure-manifests.md @@ -1355,3 +1355,71 @@ WITH_METRICS_SERVER=true make deploy-infra omission is explicit, the opt-in flag has a single documented name (`WITH_METRICS_SERVER`), and the kind overlay is self-contained under `deploy/kind/metrics-server/` so the production kustomization root is untouched. + +### dizzy load/chaos stack (kind-only opt-in) + +**File:** `deploy/kind/dizzy/kustomization.yaml` + +[dizzy](https://github.com/B42Labs/dizzy) is a scenario-driven load and +consistency tester for OpenStack control planes. Its VictoriaMetrics + Grafana +observability stack ships as a separate **opt-in** kind overlay. The default +`make deploy-infra` flow does **not** install it — the `dizzy` namespace stays +absent, and neither VictoriaMetrics nor Grafana runs unless a contributor opts +in. The production `deploy/flux-system/` overlay ships none of it. + +The overlay is self-contained, seven tracked files under `deploy/kind/dizzy/`: + +| File | Purpose | +| --- | --- | +| `namespace.yaml` | The `dizzy` Namespace, declared inline so the overlay is self-contained | +| `source-victoria-metrics.yaml` | `victoria-metrics` HelmRepository | +| `source-grafana.yaml` | `grafana` HelmRepository | +| `release-victoria-metrics.yaml` | `dizzy-victoria-metrics` HelmRelease (chart `victoria-metrics-single`, NodePort 30428, emptyDir storage, 30d retention, OTLP ingest at `/opentelemetry/v1/metrics`) | +| `release-grafana.yaml` | `dizzy-grafana` HelmRelease (anonymous Viewer access, provisioned `victoriametrics` datasource, dizzy Overview home dashboard) | +| `httproute.yaml` | The static `dizzy-grafana` HTTPRoute attaching to the `https-dizzy` listener | +| `kustomization.yaml` | Ties the resources together and generates the dashboard ConfigMap | + +Both HelmRepositories (`victoria-metrics`, `grafana`) are declared into the +`flux-system` namespace, where Flux resolves HelmRelease sourceRefs, even though +the two source files live inside the kind-only overlay. That keeps +`deploy/flux-system/**` untouched while the overlay stays self-contained. + +**Dashboard staging (`hack/dizzy.sh stage-dashboards`).** The overlay's +`configMapGenerator` wraps three dizzy dashboard JSONs, but +`deploy/kind/dizzy/dashboards/` is git-ignored; the dashboards are a +version-pinned dizzy asset staged from the release tarball, so the tree tracks +none of them. `hack/dizzy.sh stage-dashboards` copies them out of that tarball +(cached under `_output/dizzy//`) before `kubectl apply -k` runs. +`WITH_DIZZY=true make deploy-infra` performs the staging automatically; a raw +`kustomize build deploy/kind/dizzy` on a fresh checkout fails until +`stage-dashboards` has populated the directory. + +**Gateway wiring.** Two pieces are present even without `WITH_DIZZY`: the +`https-dizzy` listener on Gateway `openstack-gw` +(`deploy/kind/base/openstack-gateway.yaml`) and the `dizzy-nip-io-tls` +Certificate (`deploy/kind/infrastructure/dizzy-nip-io-tls-certificate.yaml`). +The listener admits routes from the `dizzy` namespace through a namespace +selector on the automatic `kubernetes.io/metadata.name` label, so no +ReferenceGrant is required. This is the repo's first cross-namespace-admitting +listener. The `dizzy-grafana` HTTPRoute itself ships only in the gated overlay; +without it the `dizzy.127-0-0-1.nip.io` hostname answers 404. + +**Host port mapping.** `hack/kind-config.yaml` maps host `127.0.0.1:8428` to the +node's containerPort 30428, bridging host-side OTLP export to the +VictoriaMetrics NodePort. A cluster created before this mapping existed keeps +working, but the tooling's port probe warns; recreate the cluster +(`make teardown-infra && WITH_DIZZY=true make deploy-infra`) to pick it up. + +**Opt-in usage:** + +```bash +WITH_DIZZY=true make deploy-infra +``` + +To drive the chaos soak against the ControlPlane, see +[dizzy Chaos Testing](../testing/dizzy-chaos-testing.md). + +**Posture summary.** Same shape as the entries above: the production omission is +explicit, the opt-in flag has a single documented name (`WITH_DIZZY`), and the +kind overlay is self-contained under `deploy/kind/dizzy/` so the production +kustomization root ships none of it. diff --git a/docs/reference/testing/dizzy-chaos-testing.md b/docs/reference/testing/dizzy-chaos-testing.md new file mode 100644 index 000000000..fa70d0a2c --- /dev/null +++ b/docs/reference/testing/dizzy-chaos-testing.md @@ -0,0 +1,129 @@ +--- +title: dizzy Chaos Testing +quadrant: operator +--- + + + +# dizzy Chaos Testing + +[dizzy](https://github.com/B42Labs/dizzy) is a scenario-driven load and +consistency tester for OpenStack control planes. Its `chaos` verb runs a +randomized create/mutate/delete churn soak through the OpenStack APIs against a +running ControlPlane. `make dizzy-keystone` and `make dizzy-glance` drive it +against the quick-start stack and export per-operation metrics into the dizzy +VictoriaMetrics for the Grafana dashboards. + +For the overlay that installs the VictoriaMetrics + Grafana backend, see +[Infrastructure Manifests](../infrastructure/infrastructure-manifests.md). + +## What the chaos verb does + +A chaos run loops for a fixed duration, creating, mutating, and deleting +resources at random through the service APIs. It removes the resources it +created when the run ends, including on an interrupt. After cleanup it runs a +leak check to confirm nothing it created outlived the run. Throughout, it +exports per-operation metrics over OTLP at a 15-second interval. + +The 5-minute default duration comes from the scenario's `chaos:` block. Override +it with `DIZZY_ARGS="--duration 30m"`. + +## Prerequisites + +- The dizzy stack installed on the cluster (`WITH_DIZZY=true make deploy-infra`). + The runners refuse to start when the `dizzy` namespace is absent. +- A Ready quick-start ControlPlane. The runners read the admin password from the + `controlplane-keystone-admin-credentials` Secret in the `openstack` namespace + and generate `_output/dizzy/clouds.yaml` (mode 600, cloud key `devstack-c5c3`) + from it. +- A Go toolchain on PATH. `hack/dizzy.sh` installs `bin/dizzy` with `go install` + at the pinned version, skipping the install when `go version -m bin/dizzy` + already reports that version. + +The keystone soak's small profile needs admin credentials; it creates one domain +and two roles. The glance soak uploads synthetic images of up to 40 MiB and +needs nothing beyond a member role. + +## Running a soak + +```bash +# Churn Keystone for the scenario's default 5 minutes. +make dizzy-keystone + +# Churn Glance instead. +make dizzy-glance + +# Longer run, fixed seed, keep the created resources for inspection. +DIZZY_ARGS="--duration 30m --seed 42 --no-cleanup" make dizzy-keystone +``` + +Each target runs three separate preflights before handing off to +`hack/dizzy.sh chaos `: kubectl reachability, the `dizzy` namespace, and +the ControlPlane admin Secret. Each failure prints its own message so the three +causes stay distinguishable. + +## Variables + +Every input is an optional environment override. The dizzy version pin lives only +in `hack/dizzy.sh`. + +| Variable | Effect | +| --- | --- | +| `DIZZY_SCENARIO` | Path to an alternate scenario file (default the cached `scenarios//small.yaml`). | +| `DIZZY_ARGS` | Extra dizzy flags, space-separated (for example `--duration 30m`, `--no-cleanup`, `--seed 42`). Flags with embedded quoted spaces are not supported. | +| `DIZZY_VERSION` | Pin override for the dizzy version. | +| `DIZZY_AUTH_URL` | Keystone auth URL override, skipping the host-port probe. | +| `DIZZY_SECRET` | Name of the ControlPlane admin Secret (default `controlplane-keystone-admin-credentials`). | +| `DIZZY_CP_NAMESPACE` | Namespace of the admin Secret (default `openstack`). | +| `KIND_CLUSTER` | Cluster name for the `docker port` probes (default `forge`). Note that `make deploy-infra` itself keys off `CLUSTER_NAME`. | + +## Watching the run + +Grafana serves the dizzy dashboards at `https://dizzy.127-0-0-1.nip.io`; append +`:` when that override is set to something other than 443. Access +is anonymous Viewer, so no login is needed. Three dashboards ship with the +overlay: + +| Dashboard | UID | +| --- | --- | +| dizzy Overview (the anonymous home dashboard) | `dizzy-overview` | +| dizzy API Operations | `dizzy-api-operations` | +| dizzy Time to Ready | `dizzy-time-to-ready` | + +## Metric families + +Each family carries `cloud`, `scenario`, and `service` labels. + +| Metric | Meaning | +| --- | --- | +| `dizzy_operation_duration_seconds` | Duration of a single API operation. | +| `dizzy_resource_time_to_ready_seconds` | Time for a created resource to reach a ready state. | +| `dizzy_iteration_duration_seconds` | Duration of one churn iteration. | +| `dizzy_iteration_operations_total` | Operations performed per iteration. | +| `dizzy_iterations_total` | Iterations completed. | + +`dizzy_operation_errors_total` shows up only after an operation has failed, so a +clean run never emits it. List what actually landed after a run: + +```bash +curl -fsS http://localhost:8428/api/v1/label/__name__/values +``` + +## Troubleshooting + +The tooling prints two warnings during a run. + +**No 30428 port mapping.** When the kind cluster has no host mapping for port +30428, it predates the mapping in `hack/kind-config.yaml` and OTLP ingest cannot +reach VictoriaMetrics. Recreate the cluster to fix it: + +```bash +make teardown-infra && WITH_DIZZY=true make deploy-infra +``` + +**VictoriaMetrics unreachable.** When nothing answers on `localhost:8428`, the +warning notes that metrics are exported into the void. The soak still runs and +exits by dizzy's own result, because dizzy degrades export failures to warnings. diff --git a/hack/deploy-infra.sh b/hack/deploy-infra.sh index 232b3e46f..fa46687e7 100755 --- a/hack/deploy-infra.sh +++ b/hack/deploy-infra.sh @@ -108,6 +108,11 @@ WITH_PROMETHEUS="${WITH_PROMETHEUS:-false}" # WITH_METRICS_SERVER=true to install it. WITH_METRICS_SERVER="${WITH_METRICS_SERVER:-false}" +# Gates the opt-in dizzy kind overlay (deploy/kind/dizzy) which installs +# VictoriaMetrics + Grafana for dizzy load/chaos runs. Defaults to false so the +# kind Quick Start stays minimal; set WITH_DIZZY=true to install. +WITH_DIZZY="${WITH_DIZZY:-false}" + # Gates the opt-in transparent registry pull-through cache (#564). When true, # deploy-infra brings up one small distribution-registry (registry:2/3) proxy # per upstream registry on the `kind` Docker network (start_registry_cache), @@ -1622,6 +1627,7 @@ main() { log "Chaos Mesh : ${WITH_CHAOS_MESH} (set WITH_CHAOS_MESH=true to install)" log "Prometheus stack : ${WITH_PROMETHEUS} (set WITH_PROMETHEUS=true to install)" log "metrics-server : ${WITH_METRICS_SERVER} (set WITH_METRICS_SERVER=true to install)" + log "dizzy stack : ${WITH_DIZZY} (VictoriaMetrics + Grafana for dizzy load/chaos runs; set WITH_DIZZY=true to install)" log "Registry cache : ${WITH_REGISTRY_CACHE} (set WITH_REGISTRY_CACHE=true for a local pull-through cache; local-dev only)" log "ControlPlane stack : ${WITH_CONTROLPLANE} (set WITH_CONTROLPLANE=true to provision infra via the c5c3 ControlPlane)" if [[ "${WITH_CONTROLPLANE}" == "true" ]]; then @@ -1766,6 +1772,39 @@ main() { log "metrics-server kind overlay applied (WITH_METRICS_SERVER=true)." fi + # Opt-in dizzy overlay (VictoriaMetrics + Grafana). Layered on top of the base + # so the default Quick Start stays minimal; enable with WITH_DIZZY=true. The + # overlay is self-contained (no `../../` parent-dir references), so kubectl's + # embedded kustomize renders it under the default LoadRestrictionsRootOnly + # security check — same contract as the chaos-mesh, prometheus, and + # metrics-server overlays (no `--load-restrictor` flag required, + # kubernetes/kubectl#948). + if [[ "${WITH_DIZZY}" == "true" ]]; then + # Stage the three Grafana dashboard JSONs from the pinned dizzy release into + # the git-ignored deploy/kind/dizzy/dashboards/ so the overlay's + # configMapGenerator can reference them without a parent-dir traversal (same + # LoadRestrictionsRootOnly contract as the prometheus overlay's staged JSON). + # This MUST run immediately before `kubectl apply -k` so the files exist when + # kustomize renders the ConfigMap. + "${SCRIPT_DIR}/dizzy.sh" stage-dashboards + kubectl apply -k "${REPO_ROOT}/deploy/kind/dizzy" + log "dizzy kind overlay applied (WITH_DIZZY=true)." + # VictoriaMetrics OTLP ingest is exposed on host 8428 → NodePort 30428 via + # the kind extraPortMapping in hack/kind-config.yaml. A cluster created + # before that mapping was added lacks the port, so host→VictoriaMetrics OTLP + # ingest will not work. Probe the control-plane node's published port and + # warn (but continue) so the operator knows to recreate the cluster. + local dizzy_metrics_port + dizzy_metrics_port="$(docker port "${CLUSTER_NAME}-control-plane" 30428/tcp 2>/dev/null || true)" + if [[ -z "${dizzy_metrics_port}" ]]; then + log " WARNING: cluster '${CLUSTER_NAME}' predates the dizzy metrics port mapping" + log " (host 8428 → NodePort 30428) in hack/kind-config.yaml. Host→" + log " VictoriaMetrics OTLP ingest will NOT work. To activate it, recreate" + log " the cluster with WITH_DIZZY=true (e.g." + log " \`make teardown-infra && WITH_DIZZY=true make deploy-infra\`)." + fi + fi + # the c5c3 ControlPlane stack (c5c3-operator + image and the K-ORC # GitRepository/Kustomization) is published and valid, so it would reconcile — # but running the full chain is opt-in. WITH_CONTROLPLANE=true deploys it; the @@ -1895,8 +1934,22 @@ main() { if [[ "${WITH_METRICS_SERVER}" == "true" ]]; then helm_releases+=(metrics-server) fi + # dizzy-victoria-metrics and dizzy-grafana are appended last so the relative + # ordering of the base releases is preserved exactly. wait_for_helmreleases + # resolves each release's namespace dynamically, so the names suffice. + if [[ "${WITH_DIZZY}" == "true" ]]; then + helm_releases+=(dizzy-victoria-metrics dizzy-grafana) + fi wait_for_helmreleases "${release_wait_timeout}" "${helm_releases[@]}" + if [[ "${WITH_DIZZY}" == "true" ]]; then + local dizzy_grafana_url="https://dizzy.127-0-0-1.nip.io" + if [[ "${KIND_HOST_PORT}" != "443" ]]; then + dizzy_grafana_url="${dizzy_grafana_url}:${KIND_HOST_PORT}" + fi + log "dizzy Grafana: ${dizzy_grafana_url} (anonymous read-only; dashboards land once a dizzy soak exports metrics)" + fi + # with kube-prometheus-stack Ready, flip the operator charts' # monitoring.serviceMonitor.enabled to true so Prometheus picks up the # metrics targets. Runs only when WITH_PROMETHEUS=true to keep the default diff --git a/hack/dizzy.sh b/hack/dizzy.sh new file mode 100755 index 000000000..68afcf073 --- /dev/null +++ b/hack/dizzy.sh @@ -0,0 +1,296 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright 2026 SAP SE or an SAP affiliate company +# +# SPDX-License-Identifier: Apache-2.0 + +# hack/dizzy.sh — Fetch the pinned dizzy load/chaos tester and drive it against +# the kind ControlPlane. +# +# Subcommands: +# stage-dashboards Copy dizzy's three Grafana dashboards into +# deploy/kind/dizzy/dashboards/ for the kind overlay's +# configMapGenerator. +# chaos Run the dizzy chaos soak against keystone or glance, +# exporting per-operation metrics over OTLP. +# +# The dizzy version is pinned once via the DIZZY_VERSION variable. Every other +# input is an optional environment override: DIZZY_SCENARIO, DIZZY_SECRET, +# DIZZY_CP_NAMESPACE, DIZZY_AUTH_URL, DIZZY_ARGS, and KIND_CLUSTER. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +# --------------------------------------------------------------------------- +# Pinned version — bumped by Renovate via a regex custom manager that matches +# this line literally, so it must stay verbatim and appear exactly once. +# --------------------------------------------------------------------------- +DIZZY_VERSION="${DIZZY_VERSION:-v0.1.0}" + +# Extracted-source cache and the generated clouds.yaml both live under the +# git-ignored _output/ tree. +DIZZY_CACHE_DIR="${REPO_ROOT}/_output/dizzy/${DIZZY_VERSION}" +CLOUDS_FILE="${REPO_ROOT}/_output/dizzy/clouds.yaml" + +# --------------------------------------------------------------------------- +# log — Print a timestamped log message (ISO 8601 UTC). +# Matches the pattern from hack/deploy-infra.sh. +# --------------------------------------------------------------------------- +log() { + echo "[$(date -u '+%Y-%m-%dT%H:%M:%SZ')] $*" +} + +# --------------------------------------------------------------------------- +# usage — Print the subcommand/env-override reference to stderr. +# --------------------------------------------------------------------------- +usage() { + cat >&2 < + +Subcommands: + stage-dashboards Copy dizzy's three Grafana dashboards into + deploy/kind/dizzy/dashboards/. + chaos + Run the dizzy chaos soak against the named service + using the ControlPlane admin credentials. + +Environment overrides: + DIZZY_VERSION dizzy version to pin (default ${DIZZY_VERSION}). + DIZZY_SCENARIO Scenario file path (default the cached + scenarios//small.yaml). + DIZZY_SECRET ControlPlane admin Secret name + (default controlplane-keystone-admin-credentials). + DIZZY_CP_NAMESPACE Namespace of the admin Secret (default openstack). + DIZZY_AUTH_URL Keystone auth URL override. + DIZZY_ARGS Extra args appended to the dizzy invocation. + KIND_CLUSTER kind cluster name (default forge). +EOF +} + +# --------------------------------------------------------------------------- +# ensure_cache — Populate ${DIZZY_CACHE_DIR} with dizzy's extracted sources. +# +# A cache hit (the directory already exists) skips the download entirely. +# Otherwise the release tarball is fetched into a staging dir on the SAME +# filesystem as the cache, extracted, and moved into place with an atomic +# rename — so an aborted download/extract never leaves a half-populated cache. +# --------------------------------------------------------------------------- +ensure_cache() { + if [[ -d "${DIZZY_CACHE_DIR}" ]]; then + log "dizzy ${DIZZY_VERSION} sources already cached at ${DIZZY_CACHE_DIR}." + return 0 + fi + + local url="https://github.com/B42Labs/dizzy/archive/refs/tags/${DIZZY_VERSION}.tar.gz" + log "Fetching dizzy ${DIZZY_VERSION} sources from ${url}..." + + mkdir -p "${REPO_ROOT}/_output/dizzy" + local staging + staging="$(mktemp -d "${REPO_ROOT}/_output/dizzy/.staging.XXXXXX")" + trap 'rm -rf "${staging:-}"' RETURN + + local payload="${staging}/payload" + mkdir -p "${payload}" + + if ! curl -fsSL "${url}" -o "${staging}/dizzy.tar.gz"; then + log "ERROR: failed to download dizzy sources from ${url}" + rm -rf "${staging}" + exit 1 + fi + + # --strip-components=1 drops the archive's dizzy-/ top level so the + # payload (contrib/, scenarios/, ...) is not nested one directory deeper. + if ! tar -xzf "${staging}/dizzy.tar.gz" -C "${payload}" --strip-components=1; then + log "ERROR: failed to extract dizzy sources from ${url}" + rm -rf "${staging}" + exit 1 + fi + + mv "${payload}" "${DIZZY_CACHE_DIR}" + log "dizzy ${DIZZY_VERSION} sources cached at ${DIZZY_CACHE_DIR}." +} + +# --------------------------------------------------------------------------- +# ensure_binary — Install github.com/B42Labs/dizzy at the pinned version into +# ${REPO_ROOT}/bin unless an already-installed binary reports that version. +# --------------------------------------------------------------------------- +ensure_binary() { + local bin="${REPO_ROOT}/bin/dizzy" + + # `go version -m` fails (non-zero) when the binary is absent; guarding it in + # the if-condition keeps set -e from aborting, and the buildinfo of a wrong + # version simply misses the grep, taking the install path. + if go version -m "${bin}" 2>/dev/null | grep -qF "${DIZZY_VERSION}"; then + log "dizzy ${DIZZY_VERSION} already installed at ${bin}." + return 0 + fi + + log "Installing dizzy ${DIZZY_VERSION} into ${REPO_ROOT}/bin..." + GOBIN="${REPO_ROOT}/bin" go install "github.com/B42Labs/dizzy/cmd/dizzy@${DIZZY_VERSION}" +} + +# --------------------------------------------------------------------------- +# stage_dashboards — Copy the three dizzy dashboards into the kind overlay. +# --------------------------------------------------------------------------- +stage_dashboards() { + ensure_cache + + local dest="${REPO_ROOT}/deploy/kind/dizzy/dashboards" + mkdir -p "${dest}" + + local dashboard + for dashboard in overview.json api-operations.json time-to-ready.json; do + cp -f "${DIZZY_CACHE_DIR}/contrib/otel/dashboards/${dashboard}" "${dest}/${dashboard}" + done + log "Staged dizzy dashboards into ${dest}." +} + +# --------------------------------------------------------------------------- +# generate_clouds_yaml — Write ${CLOUDS_FILE} (mode 600) from the ControlPlane +# admin Secret and the probed Keystone host port. +# --------------------------------------------------------------------------- +generate_clouds_yaml() { + local secret="${DIZZY_SECRET:-controlplane-keystone-admin-credentials}" + local namespace="${DIZZY_CP_NAMESPACE:-openstack}" + + local encoded + if ! encoded="$(kubectl get secret "${secret}" -n "${namespace}" \ + -o jsonpath='{.data.password}' 2>/dev/null)"; then + log "ERROR: could not read admin Secret '${secret}' in namespace '${namespace}'." + log " Wait for the ControlPlane to be Ready first, e.g.:" + log " kubectl wait controlplane/controlplane -n openstack --for=condition=Ready" + exit 1 + fi + + local password + password="$(printf '%s' "${encoded}" | base64 -d 2>/dev/null)" + if [[ -z "${password}" ]]; then + log "ERROR: admin Secret '${secret}' in namespace '${namespace}' has an empty password key." + exit 1 + fi + + # Escape single quotes for the YAML single-quoted scalar below. + local escaped + escaped="$(printf '%s' "${password}" | sed "s/'/''/g")" + + local auth_url + if [[ -n "${DIZZY_AUTH_URL:-}" ]]; then + auth_url="${DIZZY_AUTH_URL}" + else + local hostport="443" + local port_out + # `docker port` prints one line per binding (e.g. 127.0.0.1:443); take the + # first line's host port (the part after the last colon). Guarded so an + # unavailable docker / non-kind cluster falls back to :443 instead of + # aborting under set -e/pipefail. + if port_out="$(docker port "${KIND_CLUSTER:-forge}-control-plane" 31443/tcp 2>/dev/null)" \ + && [[ -n "${port_out}" ]]; then + local first_line="${port_out%%$'\n'*}" + hostport="${first_line##*:}" + else + log "docker port probe for the Keystone host port failed; falling back to :443." + fi + auth_url="https://keystone.127-0-0-1.nip.io:${hostport}/v3" + fi + + mkdir -p "$(dirname "${CLOUDS_FILE}")" + ( + umask 077 + cat > "${CLOUDS_FILE}" </dev/null)" \ + || [[ -z "${metrics_port}" ]]; then + log "WARNING: kind cluster '${KIND_CLUSTER:-forge}' has no 30428 host-port mapping;" + log " OTLP ingest will not reach VictoriaMetrics. Recreate the cluster with" + log " the metrics port: make teardown-infra && WITH_DIZZY=true make deploy-infra" + fi + + if ! curl -fsS http://localhost:8428/health >/dev/null 2>&1; then + log "WARNING: no VictoriaMetrics at http://localhost:8428/health; metrics will be" + log " exported into the void (dizzy degrades export failures to warnings)." + fi +} + +# --------------------------------------------------------------------------- +# run_chaos SERVICE — Run the dizzy chaos soak against keystone or glance. +# --------------------------------------------------------------------------- +run_chaos() { + local service="${1:-}" + case "${service}" in + keystone | glance) ;; + *) + usage + exit 1 + ;; + esac + + ensure_binary + ensure_cache + + # Not pre-validated: a missing scenario must reach dizzy so its own + # scenario-not-found error surfaces. + local scenario="${DIZZY_SCENARIO:-${DIZZY_CACHE_DIR}/scenarios/${service}/small.yaml}" + + generate_clouds_yaml + probe_ingest + + # Split DIZZY_ARGS on whitespace into an array so an empty value adds zero + # args (not one empty-string arg). The ${a[@]+"${a[@]}"} idiom at the + # invocation keeps stock-macOS bash 3.2 happy: expanding an empty array + # under set -u is an "unbound variable" there. + local extra_args=() + read -r -a extra_args <<< "${DIZZY_ARGS:-}" + + log "Starting dizzy ${service} chaos soak (scenario: ${scenario})..." + OS_CLIENT_CONFIG_FILE="${CLOUDS_FILE}" \ + OTEL_EXPORTER_OTLP_METRICS_ENDPOINT="http://localhost:8428/opentelemetry/v1/metrics" \ + OTEL_METRIC_EXPORT_INTERVAL=15000 \ + "${REPO_ROOT}/bin/dizzy" "${service}" chaos \ + --os-cloud devstack-c5c3 \ + --scenario "${scenario}" \ + --otel \ + ${extra_args[@]+"${extra_args[@]}"} +} + +# --------------------------------------------------------------------------- +# main +# --------------------------------------------------------------------------- +main() { + local subcommand="${1:-}" + case "${subcommand}" in + stage-dashboards) + stage_dashboards + ;; + chaos) + shift + run_chaos "$@" + ;; + *) + usage + exit 1 + ;; + esac +} + +main "$@" diff --git a/hack/kind-config.yaml b/hack/kind-config.yaml index 92e143830..9b7209d5d 100644 --- a/hack/kind-config.yaml +++ b/hack/kind-config.yaml @@ -20,3 +20,15 @@ nodes: containerPort: 31443 protocol: TCP listenAddress: "127.0.0.1" + # Bridge host :8428 → container :30428 so host-side OTLP metric export + # (`http://localhost:8428/opentelemetry/v1/metrics`) reaches the + # VictoriaMetrics server (type=NodePort, nodePort=30428) of the opt-in + # dizzy observability overlay (deploy/kind/dizzy/, gated on + # WITH_DIZZY=true in hack/deploy-infra.sh). Listening on 127.0.0.1 keeps + # it loopback-only. The mapping is unconditional so WITH_DIZZY=true can + # be enabled later on any cluster created after this change without + # recreating the cluster. + - hostPort: 8428 + containerPort: 30428 + protocol: TCP + listenAddress: "127.0.0.1" diff --git a/renovate.json b/renovate.json index 468337738..01db74371 100644 --- a/renovate.json +++ b/renovate.json @@ -112,6 +112,19 @@ "packageNameTemplate": "kubernetes/kubernetes", "versioningTemplate": "semver" }, + { + "customType": "regex", + "managerFilePatterns": [ + "/hack/dizzy\\.sh$/" + ], + "matchStrings": [ + "DIZZY_VERSION=\"\\$\\{DIZZY_VERSION:-(?v\\d+\\.\\d+\\.\\d+)\\}\"" + ], + "depNameTemplate": "B42Labs/dizzy", + "datasourceTemplate": "github-releases", + "packageNameTemplate": "B42Labs/dizzy", + "versioningTemplate": "semver" + }, { "customType": "regex", "managerFilePatterns": [ @@ -384,6 +397,22 @@ "minimumReleaseAge": "3 days", "groupName": "test tooling (install-test-deps)" }, + { + "matchManagers": ["custom.regex"], + "matchFileNames": ["hack/dizzy.sh"], + "matchPackageNames": ["B42Labs/dizzy"], + "matchUpdateTypes": ["major"], + "enabled": false + }, + { + "matchManagers": ["custom.regex"], + "matchFileNames": ["hack/dizzy.sh"], + "matchPackageNames": ["B42Labs/dizzy"], + "matchUpdateTypes": ["minor", "patch"], + "automerge": false, + "minimumReleaseAge": "3 days", + "groupName": "dizzy" + }, { "matchManagers": ["custom.regex"], "matchFileNames": ["Makefile", ".github/workflows/**.yaml"], diff --git a/tests/e2e/infrastructure/no-dizzy-when-disabled/chainsaw-test.yaml b/tests/e2e/infrastructure/no-dizzy-when-disabled/chainsaw-test.yaml new file mode 100644 index 000000000..7d0e816e2 --- /dev/null +++ b/tests/e2e/infrastructure/no-dizzy-when-disabled/chainsaw-test.yaml @@ -0,0 +1,117 @@ +# SPDX-FileCopyrightText: Copyright 2026 SAP SE or an SAP affiliate company +# +# SPDX-License-Identifier: Apache-2.0 + +# Chainsaw E2E test pinning the default-state non-regression invariant for the +# dizzy observability kind opt-in: when WITH_DIZZY is unset (or set to anything +# other than the literal string "true"), `make deploy-infra` MUST NOT install +# the dizzy Namespace, the VictoriaMetrics/Grafana HelmRepositories, or the +# dizzy HelmReleases. +# +# Why this test exists +# The shell unit tests (tests/unit/hack/deploy_infra_dizzy_flag_test.sh, +# tests/unit/deploy/dizzy_overlay_test.sh) exercise the gating logic and +# overlay render at the source level. Neither positively asserts "default +# cluster has no dizzy footprint", so a regression that accidentally installs +# it (a stray `kubectl apply -k deploy/kind/dizzy` outside the WITH_DIZZY gate, +# or a future kustomize merge that pulls the overlay into the base) would slip +# through. This suite closes that gap by asserting absence end-to-end against +# the running cluster, mirroring the sibling no-metrics-server-when-disabled +# suite. +# +# Lane wiring: +# This test runs in the default e2e-infra lane (which exercises +# `make deploy-infra` without WITH_DIZZY), so the absence assertions hold. +# There is no CI lane that opts the flag on today, so this negative gate is the +# primary end-to-end coverage of the default posture. +# +# Scope gating — none. This absence invariant is also true on production +# overlays (deploy/flux-system/ does not install the dizzy stack at all), so the +# test is environment-agnostic like no-metrics-server-when-disabled. +# +# Why a single script and not declarative `error` blocks: asserting "no +# HelmRelease named dizzy-* in any namespace" requires a list-and-filter, which +# is more naturally expressed in shell. Consolidating into one script also keeps +# the catch-block dump aligned with the assertion sequence. +# +--- +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: no-dizzy-when-disabled +spec: + # Namespace get, two helmrepository gets, two helmrelease list counts, plus a + # diagnostic dump on failure. Realistic worst case is sub-second; budget 2m + # for cluster slowness margin. + timeouts: + assert: 2m + exec: 2m + steps: + - try: + - script: + timeout: 2m + # Chainsaw defaults to `sh`, which rejects `set -o pipefail`. Pin bash + # to match the rest of the infra/keystone e2e scripts. + shell: bash + content: | + set -euo pipefail + + # ── Assertion 1: no dizzy Namespace ── + # The only producer is deploy/kind/dizzy/namespace.yaml, applied solely + # inside the WITH_DIZZY=true gate. + if kubectl get namespace dizzy >/dev/null 2>&1; then + echo "FAIL: Namespace 'dizzy' exists on a default cluster." + echo " Source of regression: hack/deploy-infra.sh ran the dizzy overlay" + echo " apply outside the WITH_DIZZY gate, or the overlay was pulled into" + echo " deploy/kind/base." + exit 1 + fi + echo "OK: no Namespace 'dizzy'" + + # ── Assertion 2: no dizzy HelmRepositories in flux-system ── + # The only producers are deploy/kind/dizzy/source-{victoria-metrics, + # grafana}.yaml, applied solely inside the WITH_DIZZY=true gate. + for repo in victoria-metrics grafana; do + if kubectl get helmrepository.source.toolkit.fluxcd.io "${repo}" \ + -n flux-system >/dev/null 2>&1; then + echo "FAIL: HelmRepository '${repo}' exists in 'flux-system' on a default cluster." + echo " Source of regression: the dizzy overlay was applied without" + echo " WITH_DIZZY=true, or a source was pulled into deploy/flux-system." + exit 1 + fi + echo "OK: no HelmRepository '${repo}' in 'flux-system'" + done + + # ── Assertion 3: no dizzy HelmReleases anywhere ── + # `kubectl get -A -o name` returns 0 even when the list is empty; we + # count lines rather than relying on exit codes. + for rel in dizzy-victoria-metrics dizzy-grafana; do + hr_count=$(kubectl get helmrelease.helm.toolkit.fluxcd.io --all-namespaces \ + --field-selector=metadata.name="${rel}" \ + -o name 2>/dev/null | wc -l | tr -d '[:space:]') + if [ "${hr_count}" != "0" ]; then + echo "FAIL: HelmRelease '${rel}' exists (count=${hr_count}) on a default cluster." + kubectl get helmrelease.helm.toolkit.fluxcd.io --all-namespaces \ + --field-selector=metadata.name="${rel}" -o wide 2>&1 || true + exit 1 + fi + echo "OK: no HelmRelease '${rel}' exists" + done + + echo "PASS: default kind deploy footprint matches the WITH_DIZZY=false contract." + catch: + # On failure, dump the cluster's dizzy footprint for triage. Each block is + # independently scoped so a missing resource does not short-circuit the rest + # of the dump. + - script: + content: | + echo "=== Namespace dizzy ===" + kubectl get namespace dizzy -o wide 2>&1 || true + echo "=== HelmRepositories dizzy sources (flux-system) ===" + kubectl get helmrepository.source.toolkit.fluxcd.io -n flux-system \ + victoria-metrics grafana -o wide 2>&1 || true + echo "=== HelmReleases named dizzy-* (any namespace) ===" + kubectl get helmrelease.helm.toolkit.fluxcd.io --all-namespaces \ + --field-selector=metadata.name=dizzy-victoria-metrics -o wide 2>&1 || true + kubectl get helmrelease.helm.toolkit.fluxcd.io --all-namespaces \ + --field-selector=metadata.name=dizzy-grafana -o wide 2>&1 || true diff --git a/tests/unit/deploy/dizzy_overlay_test.sh b/tests/unit/deploy/dizzy_overlay_test.sh new file mode 100644 index 000000000..d7a9f202b --- /dev/null +++ b/tests/unit/deploy/dizzy_overlay_test.sh @@ -0,0 +1,246 @@ +#!/bin/bash +# SPDX-FileCopyrightText: Copyright 2026 SAP SE or an SAP affiliate company +# +# SPDX-License-Identifier: Apache-2.0 + +# Verify the opt-in dizzy observability overlay (deploy/kind/dizzy): +# - all 7 tracked overlay files carry the repo SPDX header. +# - with the three dashboard JSONs staged into the git-ignored +# deploy/kind/dizzy/dashboards/ dir, `kustomize build deploy/kind/dizzy` +# renders exactly the expected resource set under the default +# LoadRestrictionsRootOnly security check (no --load-restrictor flag): +# one Namespace (dizzy), two HelmRepositories (victoria-metrics + grafana in +# flux-system), two HelmReleases (dizzy-victoria-metrics + dizzy-grafana in +# dizzy), one HTTPRoute (dizzy-grafana), and one ConfigMap +# (grafana-dashboards) wrapping the three dashboard files. +# - with dashboards/ absent, `kustomize build` FAILS — the documented staging +# contract (hack/dizzy.sh stage-dashboards must run first). +# - the victoria-metrics-single chart version pins to a single 0.x minor +# window (>=0.N.x <0.(N+1).0), never a range spanning the whole 0.x line, +# since a 0.x chart may break on any minor bump. +# +# STAGED-ASSET SAFETY a developer may have REAL dashboards staged at +# deploy/kind/dizzy/dashboards/ (hack/dizzy.sh stage-dashboards writes them +# there). This test moves any pre-existing dir aside up front and restores it +# via an EXIT trap, so it never destroys staged assets and never fails +# spuriously; the placeholder JSONs it stages for the render assertions are +# `{}` and are removed again afterwards. +# +# Usage: bash tests/unit/deploy/dizzy_overlay_test.sh + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" + +PASS=0 +FAIL=0 +SKIP=0 + +# shellcheck source=tests/lib/assertions.sh +source "$PROJECT_ROOT/tests/lib/assertions.sh" + +DIZZY_DIR="$PROJECT_ROOT/deploy/kind/dizzy" +DASHBOARDS_DIR="$DIZZY_DIR/dashboards" +DASHBOARD_FILES=(overview.json api-operations.json time-to-ready.json) + +# Backup location for a developer's pre-existing dashboards/ dir, empty when +# none existed at start. +DASHBOARDS_BACKUP="" + +# --------------------------------------------------------------------------- +# Dashboards-dir guard — never destroy developer-staged assets. +# --------------------------------------------------------------------------- +# Move any pre-existing dashboards/ dir into a temp backup so the render tests +# operate on a known-empty slate. Recorded in DASHBOARDS_BACKUP for restore. +guard_setup_dashboards() { + if [[ -e "$DASHBOARDS_DIR" ]]; then + DASHBOARDS_BACKUP="$(mktemp -d)" + mv "$DASHBOARDS_DIR" "$DASHBOARDS_BACKUP/dashboards" + fi +} + +# Remove any test-created dashboards/ dir, then restore the developer's original +# (moved aside in guard_setup_dashboards) byte-for-byte. Registered on EXIT so a +# mid-test failure still restores it. +guard_restore_dashboards() { + rm -rf "$DASHBOARDS_DIR" + if [[ -n "$DASHBOARDS_BACKUP" && -d "$DASHBOARDS_BACKUP/dashboards" ]]; then + mv "$DASHBOARDS_BACKUP/dashboards" "$DASHBOARDS_DIR" + rmdir "$DASHBOARDS_BACKUP" 2>/dev/null || true + fi +} + +# Stage the three placeholder dashboard JSONs so the configMapGenerator's +# `files:` resolve. Content is `{}` — the render assertions only care that the +# files exist and become ConfigMap data keys. +stage_placeholder_dashboards() { + mkdir -p "$DASHBOARDS_DIR" + local f + for f in "${DASHBOARD_FILES[@]}"; do + printf '{}' > "$DASHBOARDS_DIR/$f" + done +} + +# Count documents of a given kind in a rendered manifest stream read on stdin. +count_kind() { + local kind="$1" + grep -cE "^kind: ${kind}\$" 2>/dev/null || true +} + +# --- Test 1: all 7 tracked overlay files carry the SPDX header --- +test_overlay_files_have_spdx() { + echo "Test: every tracked deploy/kind/dizzy file carries the SPDX header" + + local tracked + tracked="$(git -C "$PROJECT_ROOT" ls-files 'deploy/kind/dizzy')" + + local count + count="$(printf '%s\n' "$tracked" | grep -c . || true)" + assert_eq "deploy/kind/dizzy has exactly 7 tracked files" "7" "$count" + + local f + while IFS= read -r f; do + [[ -z "$f" ]] && continue + assert_file_contains "$(basename "$f") has SPDX FileCopyrightText header" \ + "$PROJECT_ROOT/$f" "SPDX-FileCopyrightText: Copyright 2026 SAP SE" + assert_file_contains "$(basename "$f") has SPDX-License-Identifier: Apache-2.0" \ + "$PROJECT_ROOT/$f" "SPDX-License-Identifier: Apache-2.0" + done <<< "$tracked" +} + +# --- Test 2: kustomize build FAILS when dashboards/ is unstaged --- +test_build_fails_without_dashboards() { + echo "Test: kustomize build deploy/kind/dizzy FAILS when dashboards/ is absent" + + if ! command -v kustomize >/dev/null 2>&1; then + echo " SKIP: kustomize not installed (1 check skipped)" + SKIP=$((SKIP + 1)) + return + fi + + # Guarantee absence regardless of test order. + rm -rf "$DASHBOARDS_DIR" + + local output exit_code + output="$(kustomize build "$DIZZY_DIR" 2>&1)" + exit_code=$? + assert_nonzero_exit "kustomize build fails without the staged dashboards/ dir" "$exit_code" +} + +# --- Test 3: kustomize build renders the expected set with dashboards staged --- +test_build_renders_expected_set() { + echo "Test: kustomize build deploy/kind/dizzy renders the expected resource set" + + if ! command -v kustomize >/dev/null 2>&1; then + echo " SKIP: kustomize not installed (13 checks skipped)" + SKIP=$((SKIP + 13)) + return + fi + if ! command -v yq >/dev/null 2>&1; then + echo " SKIP: yq not installed (13 checks skipped)" + SKIP=$((SKIP + 13)) + return + fi + + stage_placeholder_dashboards + + # Mirror the production invocation: NO --load-restrictor flag. kubectl's + # embedded kustomize (used by hack/deploy-infra.sh) does not expose one. + local rendered + if ! rendered="$(kustomize build "$DIZZY_DIR" 2>&1)"; then + echo " FAIL: kustomize build $DIZZY_DIR failed (default LoadRestrictionsRootOnly):" + echo "$rendered" | head -20 + FAIL=$((FAIL + 13)) + rm -rf "$DASHBOARDS_DIR" + return + fi + + # Namespace: exactly one, named dizzy. + assert_eq "renders exactly one Namespace" \ + "1" "$(printf '%s\n' "$rendered" | count_kind Namespace)" + assert_eq "the Namespace is named dizzy" \ + "dizzy" "$(printf '%s\n' "$rendered" | yq -r 'select(.kind == "Namespace") | .metadata.name' | head -n1)" + + # HelmRepositories: exactly two, both in flux-system. + assert_eq "renders exactly two HelmRepositories" \ + "2" "$(printf '%s\n' "$rendered" | count_kind HelmRepository)" + assert_eq "HelmRepository/victoria-metrics lives in flux-system" \ + "flux-system" "$(printf '%s\n' "$rendered" | yq -r 'select(.kind == "HelmRepository" and .metadata.name == "victoria-metrics") | .metadata.namespace' | head -n1)" + assert_eq "HelmRepository/grafana lives in flux-system" \ + "flux-system" "$(printf '%s\n' "$rendered" | yq -r 'select(.kind == "HelmRepository" and .metadata.name == "grafana") | .metadata.namespace' | head -n1)" + + # HelmReleases: exactly two, both in dizzy. + assert_eq "renders exactly two HelmReleases" \ + "2" "$(printf '%s\n' "$rendered" | count_kind HelmRelease)" + assert_eq "HelmRelease/dizzy-victoria-metrics lives in dizzy" \ + "dizzy" "$(printf '%s\n' "$rendered" | yq -r 'select(.kind == "HelmRelease" and .metadata.name == "dizzy-victoria-metrics") | .metadata.namespace' | head -n1)" + assert_eq "HelmRelease/dizzy-grafana lives in dizzy" \ + "dizzy" "$(printf '%s\n' "$rendered" | yq -r 'select(.kind == "HelmRelease" and .metadata.name == "dizzy-grafana") | .metadata.namespace' | head -n1)" + + # HTTPRoute: exactly one, named dizzy-grafana in dizzy. + assert_eq "renders exactly one HTTPRoute" \ + "1" "$(printf '%s\n' "$rendered" | count_kind HTTPRoute)" + assert_eq "HTTPRoute/dizzy-grafana lives in dizzy" \ + "dizzy" "$(printf '%s\n' "$rendered" | yq -r 'select(.kind == "HTTPRoute" and .metadata.name == "dizzy-grafana") | .metadata.namespace' | head -n1)" + + # ConfigMap: exactly one, named grafana-dashboards, with the three data keys. + assert_eq "renders exactly one ConfigMap" \ + "1" "$(printf '%s\n' "$rendered" | count_kind ConfigMap)" + assert_eq "the ConfigMap is named grafana-dashboards in dizzy" \ + "dizzy" "$(printf '%s\n' "$rendered" | yq -r 'select(.kind == "ConfigMap" and .metadata.name == "grafana-dashboards") | .metadata.namespace' | head -n1)" + + local keys + keys="$(printf '%s\n' "$rendered" | yq -r 'select(.kind == "ConfigMap" and .metadata.name == "grafana-dashboards") | .data | keys | .[]' | sort | tr '\n' ' ' | sed 's/ $//')" + assert_eq "the ConfigMap wraps the three dashboard JSONs" \ + "api-operations.json overview.json time-to-ready.json" "$keys" + + # Remove the placeholders we staged (the EXIT trap is the safety net). + rm -rf "$DASHBOARDS_DIR" +} + +# --- Test 4: the victoria-metrics chart pins to a single 0.x minor --- +test_victoria_metrics_version_is_single_minor() { + echo "Test: the dizzy-victoria-metrics chart version pins to a single 0.x minor" + + if ! command -v yq >/dev/null 2>&1; then + echo " SKIP: yq not installed (1 check skipped)" + SKIP=$((SKIP + 1)) + return + fi + + local version + version="$(yq -r '.spec.chart.spec.version' "$DIZZY_DIR/release-victoria-metrics.yaml")" + + # victoria-metrics-single is a 0.x chart, so SemVer permits breaking changes + # on every minor bump and the constraint MUST bound a single minor + # (>=0.N.x <0.(N+1).0). A range spanning the whole 0.x line (>=0.40.0 <1.0.0) + # lets Flux auto-pull a breaking minor that can rename the server Service the + # hard-coded Grafana datasource URL depends on — silent "no data" dashboards. + if [[ "$version" =~ ^\>=0\.([0-9]+)\.[0-9]+\ \<0\.([0-9]+)\.0$ ]]; then + local lower_minor="${BASH_REMATCH[1]}" upper_minor="${BASH_REMATCH[2]}" + assert_eq "the version window spans exactly one 0.x minor ($version)" \ + "$((lower_minor + 1))" "$upper_minor" + else + echo " FAIL: version '$version' is not a single 0.x minor window (>=0.N.x <0.(N+1).0)" + FAIL=$((FAIL + 1)) + fi +} + +# --------------------------------------------------------------------------- +# Run +# --------------------------------------------------------------------------- +guard_setup_dashboards +trap guard_restore_dashboards EXIT + +test_overlay_files_have_spdx +test_build_fails_without_dashboards +test_build_renders_expected_set +test_victoria_metrics_version_is_single_minor + +echo "" +echo "Results: $PASS passed, $FAIL failed, $SKIP skipped" +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi +exit 0 diff --git a/tests/unit/hack/deploy_infra_dizzy_flag_test.sh b/tests/unit/hack/deploy_infra_dizzy_flag_test.sh new file mode 100644 index 000000000..ecd20f012 --- /dev/null +++ b/tests/unit/hack/deploy_infra_dizzy_flag_test.sh @@ -0,0 +1,384 @@ +#!/bin/bash +# SPDX-FileCopyrightText: Copyright 2026 SAP SE or an SAP affiliate company +# +# SPDX-License-Identifier: Apache-2.0 + +# Verify hack/deploy-infra.sh gates the dizzy observability stack +# (VictoriaMetrics + Grafana) behind WITH_DIZZY so the kind Quick Start stays +# minimal by default and only installs the stack when explicitly requested. +# +# Implementation: bash + tests/lib/assertions.sh — mirrors the sibling +# tests/unit/hack/deploy_infra_metrics_server_flag_test.sh pattern. The repo has +# zero .bats files and no shellspec runner; introducing one would add an +# undeclared test dependency. +# +# Strategy: hybrid — source the script (the `BASH_SOURCE[0] == ${0}` guard at +# the bottom of deploy-infra.sh keeps main() from auto-running) to assert the +# runtime default of WITH_DIZZY for each env scenario, and grep the script +# source to lock in the three strict gate locations: +# 1. Step 3 overlay apply (stage-dashboards + kubectl apply -k deploy/kind/dizzy) +# 2. Phase 3 helm-release wait list append (dizzy-victoria-metrics, dizzy-grafana) +# 3. post-wait Grafana URL log +# The configuration-banner line is asserted via grep so the user-visible summary +# stays in lockstep with the runtime value. The 8428 kind extraPortMapping is +# pinned here too: it must survive a KIND_HOST_PORT override byte-for-byte so a +# non-privileged host port never disturbs the dizzy OTLP ingest bridge. +# +# Usage: bash tests/unit/hack/deploy_infra_dizzy_flag_test.sh + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +DEPLOY_INFRA_SH="$PROJECT_ROOT/hack/deploy-infra.sh" +KIND_CONFIG_FILE="$PROJECT_ROOT/hack/kind-config.yaml" +DIZZY_KUSTOMIZATION="$PROJECT_ROOT/deploy/kind/dizzy/kustomization.yaml" + +PASS=0 +FAIL=0 +SKIP=0 + +# shellcheck source=tests/lib/assertions.sh +source "$PROJECT_ROOT/tests/lib/assertions.sh" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +# resolve_with_dizzy [env_var=value...] +# Sources deploy-infra.sh in a subshell with the supplied env overrides and +# echoes the resolved value of WITH_DIZZY after the configuration block runs. +# The `BASH_SOURCE[0] == ${0}` guard at the bottom of deploy-infra.sh keeps +# main() from auto-running when the script is sourced. +resolve_with_dizzy() { + ( + for assignment in "$@"; do + export "${assignment?}" + done + # shellcheck source=/dev/null + source "$DEPLOY_INFRA_SH" + printf '%s' "${WITH_DIZZY}" + ) +} + +# run_render OUT_PATH KIND_HOST_PORT +# Sources the script and calls render_kind_config in a subshell with the given +# KIND_HOST_PORT, writing to OUT_PATH. Echoes combined output and returns the +# function's exit status. Subshell isolates env mutations and keeps main() from +# running (BASH_SOURCE guard). Mirrors deploy_infra_kind_host_port_test.sh. +run_render() { + local out_path="$1" + local kind_host_port="$2" + ( + export KIND_HOST_PORT="${kind_host_port}" + # shellcheck source=/dev/null + source "$DEPLOY_INFRA_SH" + render_kind_config "${out_path}" + ) 2>&1 +} + +# --------------------------------------------------------------------------- +# Test 1: WITH_DIZZY defaults to false +# --------------------------------------------------------------------------- +test_default_is_false() { + echo "Test: WITH_DIZZY defaults to false" + + # Unset any inherited value so we observe the script's own default. + local resolved + resolved="$(unset WITH_DIZZY; resolve_with_dizzy)" + assert_eq "WITH_DIZZY defaults to false" "false" "$resolved" +} + +# --------------------------------------------------------------------------- +# Test 2: explicit WITH_DIZZY=true +# --------------------------------------------------------------------------- +test_explicit_true() { + echo "Test: WITH_DIZZY=true is preserved" + + local resolved + resolved="$(resolve_with_dizzy WITH_DIZZY=true)" + assert_eq "WITH_DIZZY=true is preserved" "true" "$resolved" +} + +# --------------------------------------------------------------------------- +# Test 3: explicit WITH_DIZZY=false +# --------------------------------------------------------------------------- +test_explicit_false() { + echo "Test: WITH_DIZZY=false is preserved" + + local resolved + resolved="$(resolve_with_dizzy WITH_DIZZY=false)" + assert_eq "WITH_DIZZY=false is preserved" "false" "$resolved" +} + +# --------------------------------------------------------------------------- +# Test 4: defensive non-true value +# A typo like WITH_DIZZY=yes must NOT enable the overlay; every gate uses the +# strict `== "true"` comparison. We assert the value passes through verbatim AND +# that all gate sites use exact-match. There are exactly three runtime gates: +# - Step 3 stage-dashboards + overlay apply +# - Phase 3 helm_releases append +# - post-wait Grafana URL log +# --------------------------------------------------------------------------- +test_non_true_value_does_not_trigger_install() { + echo "Test: WITH_DIZZY=yes passes through but does not trigger install" + + local resolved + resolved="$(resolve_with_dizzy WITH_DIZZY=yes)" + assert_eq "WITH_DIZZY=yes is preserved verbatim" "yes" "$resolved" + + local gate_count + gate_count="$(grep -cE '"\$\{WITH_DIZZY\}" == "true"' "$DEPLOY_INFRA_SH" || true)" + assert_eq "deploy-infra.sh has exactly 3 strict WITH_DIZZY==true gates" "3" "$gate_count" +} + +# --------------------------------------------------------------------------- +# Test 5: configuration banner line +# The user-visible summary must surface the WITH_DIZZY state so operators can +# spot accidental opt-ins / opt-outs at a glance, and name the stack it gates. +# --------------------------------------------------------------------------- +test_banner_includes_dizzy_line() { + echo "Test: configuration banner surfaces WITH_DIZZY state" + + assert_file_contains \ + "deploy-infra.sh banner mentions the dizzy stack" \ + "$DEPLOY_INFRA_SH" \ + 'dizzy stack' + + assert_file_contains \ + "deploy-infra.sh banner names the VictoriaMetrics + Grafana stack the flag gates" \ + "$DEPLOY_INFRA_SH" \ + 'VictoriaMetrics + Grafana' +} + +# --------------------------------------------------------------------------- +# Test 6: Step 3 overlay apply is gated, and stage-dashboards runs first +# The kustomize apply for deploy/kind/dizzy must live inside the WITH_DIZZY gate +# so the default Quick Start does not install it, and dizzy.sh stage-dashboards +# MUST run inside the SAME gate BEFORE the apply so the configMapGenerator's +# staged JSONs exist when kustomize renders the ConfigMap. +# --------------------------------------------------------------------------- +test_dizzy_apply_is_gated_after_staging() { + echo "Test: dizzy overlay apply is gated and staged after dizzy.sh stage-dashboards" + + # There is exactly one apply of the dizzy overlay. + local apply_hits + apply_hits="$(grep -cF 'kubectl apply -k "${REPO_ROOT}/deploy/kind/dizzy"' "$DEPLOY_INFRA_SH" || true)" + assert_eq "deploy-infra.sh applies the dizzy overlay exactly once" "1" "$apply_hits" + + local apply_line stage_line gate_before_apply gate_before_stage + apply_line="$(grep -nF 'kubectl apply -k "${REPO_ROOT}/deploy/kind/dizzy"' "$DEPLOY_INFRA_SH" | head -1 | cut -d: -f1)" + stage_line="$(grep -nF 'dizzy.sh" stage-dashboards' "$DEPLOY_INFRA_SH" | head -1 | cut -d: -f1)" + + assert_not_empty "kubectl apply -k deploy/kind/dizzy line is found" "$apply_line" + assert_not_empty "dizzy.sh stage-dashboards line is found" "$stage_line" + + # The most recent WITH_DIZZY gate above each anchor line. + gate_before_apply="$(grep -n '"${WITH_DIZZY}" == "true"' "$DEPLOY_INFRA_SH" | awk -F: -v target="${apply_line:-0}" '$1 < target { last = $1 } END { print last }')" + gate_before_stage="$(grep -n '"${WITH_DIZZY}" == "true"' "$DEPLOY_INFRA_SH" | awk -F: -v target="${stage_line:-0}" '$1 < target { last = $1 } END { print last }')" + + assert_not_empty "WITH_DIZZY gate precedes the overlay apply" "$gate_before_apply" + assert_not_empty "WITH_DIZZY gate precedes stage-dashboards" "$gate_before_stage" + + # stage-dashboards must run before the apply, and both under the same gate. + if [[ -n "$stage_line" && -n "$apply_line" && "$stage_line" -lt "$apply_line" ]]; then + echo " PASS: stage-dashboards ($stage_line) runs before the overlay apply ($apply_line)" + PASS=$((PASS + 1)) + else + echo " FAIL: stage-dashboards ($stage_line) does not run before the overlay apply ($apply_line)" + FAIL=$((FAIL + 1)) + fi + + assert_eq "stage-dashboards and the overlay apply share the same WITH_DIZZY gate" \ + "$gate_before_apply" "$gate_before_stage" +} + +# --------------------------------------------------------------------------- +# Test 7: Phase 3 wait list append is gated, base array is byte-identical +# The Phase 3 wait list must NOT statically include the dizzy releases; they +# must be appended dynamically inside the WITH_DIZZY gate so the relative +# ordering of the base releases is preserved. The base array declaration line is +# pinned byte-for-byte so an accidental edit to it is caught here. +# --------------------------------------------------------------------------- +test_phase3_append_is_gated() { + echo "Test: dizzy releases are appended dynamically to the helm-release wait list" + + # The base array must NOT include the dizzy releases inline. + assert_file_not_contains \ + "deploy-infra.sh does not hard-code dizzy-victoria-metrics in the base wait list" \ + "$DEPLOY_INFRA_SH" \ + 'garage-operator dizzy-victoria-metrics' + + # The base array line is pinned byte-for-byte (copied verbatim from the + # script) so an accidental reorder or rename of a base release trips here. + assert_file_contains \ + "helm_releases base array line is byte-identical to the expected literal" \ + "$DEPLOY_INFRA_SH" \ + 'local helm_releases=(prometheus-operator-crds openbao mariadb-operator-crds mariadb-operator external-secrets memcached-operator envoy-gateway garage-operator)' + + # The dynamic append exists. + assert_file_contains \ + "dizzy releases are appended via helm_releases+=(dizzy-victoria-metrics dizzy-grafana)" \ + "$DEPLOY_INFRA_SH" \ + 'helm_releases+=(dizzy-victoria-metrics dizzy-grafana)' + + # And the append sits inside a WITH_DIZZY gate. + local append_line gate_before_append + append_line="$(grep -nF 'helm_releases+=(dizzy-victoria-metrics dizzy-grafana)' "$DEPLOY_INFRA_SH" | head -1 | cut -d: -f1)" + gate_before_append="$(grep -n '"${WITH_DIZZY}" == "true"' "$DEPLOY_INFRA_SH" | awk -F: -v target="${append_line:-0}" '$1 < target { last = $1 } END { print last }')" + assert_not_empty "the dizzy helm_releases append line is found" "$append_line" + assert_not_empty "a WITH_DIZZY gate precedes the dizzy helm_releases append" "$gate_before_append" +} + +# --------------------------------------------------------------------------- +# Test 8: overlay is self-contained (no parent-directory resource entries) +# kubectl's embedded kustomize (used by the production apply) does NOT expose +# --load-restrictor (kubernetes/kubectl#948), so a `../..` resource reference +# would re-introduce the LoadRestrictionsRootOnly failure. Assert zero such +# entries in the kustomization AND in every tracked file of the overlay. The +# pattern is anchored to a YAML list item (`- ../..`) — the same discipline as +# the metrics-server sibling — so the prose that DOCUMENTS the no-`../..` +# contract in the overlay's own comments does not trip the check. +# --------------------------------------------------------------------------- +test_overlay_is_self_contained() { + echo "Test: dizzy overlay has no '../..' parent-directory resource entries" + + if [[ ! -f "$DIZZY_KUSTOMIZATION" ]]; then + echo " FAIL: $DIZZY_KUSTOMIZATION does not exist" + FAIL=$((FAIL + 1)) + return + fi + + local kust_refs + kust_refs="$( { grep -E '^[[:space:]]*-[[:space:]]+\.\./\.\.' "$DIZZY_KUSTOMIZATION" || true; } | wc -l | tr -d '[:space:]')" + assert_eq "kustomization.yaml has no '../..' parent-directory resource entries" "0" "$kust_refs" + + # Every tracked file of the overlay must be free of '../..' resource entries. + # `git ls-files` yields exactly the tracked set (the git-ignored dashboards/ + # dir is excluded even when a developer has staged JSONs into it). + local tracked f total_refs=0 checked=0 + tracked="$(git -C "$PROJECT_ROOT" ls-files 'deploy/kind/dizzy' 2>/dev/null)" + while IFS= read -r f; do + [[ -z "$f" ]] && continue + checked=$((checked + 1)) + local n + n="$( { grep -E '^[[:space:]]*-[[:space:]]+\.\./\.\.' "$PROJECT_ROOT/$f" || true; } | wc -l | tr -d '[:space:]')" + total_refs=$((total_refs + n)) + done <<< "$tracked" + + if [[ "$checked" -eq 0 ]]; then + echo " FAIL: git ls-files returned no tracked overlay files" + FAIL=$((FAIL + 1)) + return + fi + assert_eq "no tracked overlay file ($checked scanned) has a '../..' resource entry" \ + "0" "$total_refs" +} + +# --------------------------------------------------------------------------- +# Test 9: kind-config.yaml carries the 8428 → 30428 OTLP ingest mapping +# The dizzy OTLP ingest bridge (host 8428 → NodePort 30428) is declared +# unconditionally so WITH_DIZZY=true can be enabled on any cluster created after +# the mapping landed. Pin exactly one such mapping with the expected fields. +# --------------------------------------------------------------------------- +test_kind_config_has_8428_mapping() { + echo "Test: hack/kind-config.yaml has exactly one 8428 → 30428 mapping with the expected fields" + + if ! command -v yq >/dev/null 2>&1; then + echo " SKIP: yq not installed (5 checks skipped)" + SKIP=$((SKIP + 5)) + return + fi + + local count + count="$(yq -r '[.nodes[0].extraPortMappings[] | select(.hostPort == 8428)] | length' "$KIND_CONFIG_FILE")" + assert_eq "exactly one extraPortMappings entry has hostPort 8428" "1" "$count" + + assert_eq "8428 mapping targets containerPort 30428" \ + "30428" \ + "$(yq -r '.nodes[0].extraPortMappings[] | select(.hostPort == 8428) | .containerPort' "$KIND_CONFIG_FILE")" + + assert_eq "8428 mapping uses protocol TCP" \ + "TCP" \ + "$(yq -r '.nodes[0].extraPortMappings[] | select(.hostPort == 8428) | .protocol' "$KIND_CONFIG_FILE")" + + assert_eq "8428 mapping listens on 127.0.0.1 only" \ + "127.0.0.1" \ + "$(yq -r '.nodes[0].extraPortMappings[] | select(.hostPort == 8428) | .listenAddress' "$KIND_CONFIG_FILE")" + + # There must still be a hostPort=443 entry (the Envoy data-plane bridge) so + # this mapping is genuinely additive, not a replacement. + assert_eq "the 443 Envoy data-plane mapping still exists alongside 8428" \ + "1" \ + "$(yq -r '[.nodes[0].extraPortMappings[] | select(.hostPort == 443)] | length' "$KIND_CONFIG_FILE")" +} + +# --------------------------------------------------------------------------- +# Test 10: the 8428 mapping survives a KIND_HOST_PORT override byte-for-byte +# render_kind_config rewrites only the hostPort=443 entry to KIND_HOST_PORT; the +# dizzy 8428 mapping must be untouched, or a non-privileged host port would +# collaterally break the dizzy OTLP ingest bridge. +# --------------------------------------------------------------------------- +test_8428_mapping_survives_host_port_override() { + echo "Test: render_kind_config KIND_HOST_PORT=8443 leaves the 8428 mapping byte-identical" + + if ! command -v yq >/dev/null 2>&1; then + echo " SKIP: yq not installed (3 checks skipped)" + SKIP=$((SKIP + 3)) + return + fi + + local tmp + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' RETURN + + local out="$tmp/rendered.yaml" + local output exit_code + output="$(run_render "$out" "8443")" + exit_code=$? + + assert_eq "render_kind_config exits 0 with KIND_HOST_PORT=8443" "0" "$exit_code" + + if [[ ! -f "$out" ]]; then + echo " FAIL: render_kind_config did not produce the output file" + FAIL=$((FAIL + 1)) + echo " output was: $output" + return + fi + + # The 443 entry mutated to 8443. + assert_eq "the 443 Envoy mapping mutated to the override port 8443" \ + "8443" \ + "$(yq -r '.nodes[0].extraPortMappings[] | select(.hostPort == 8443) | .hostPort' "$out")" + + # The 8428 dizzy mapping is byte-identical between source and rendered output. + if diff <(yq -r '.nodes[0].extraPortMappings[] | select(.hostPort == 8428)' "$KIND_CONFIG_FILE") \ + <(yq -r '.nodes[0].extraPortMappings[] | select(.hostPort == 8428)' "$out") >/dev/null; then + echo " PASS: the 8428 dizzy mapping is byte-identical after the KIND_HOST_PORT override" + PASS=$((PASS + 1)) + else + echo " FAIL: the 8428 dizzy mapping changed under the KIND_HOST_PORT override" + FAIL=$((FAIL + 1)) + fi +} + +# --------------------------------------------------------------------------- +# Run +# --------------------------------------------------------------------------- +test_default_is_false +test_explicit_true +test_explicit_false +test_non_true_value_does_not_trigger_install +test_banner_includes_dizzy_line +test_dizzy_apply_is_gated_after_staging +test_phase3_append_is_gated +test_overlay_is_self_contained +test_kind_config_has_8428_mapping +test_8428_mapping_survives_host_port_override + +echo "" +echo "Results: $PASS passed, $FAIL failed, $SKIP skipped" +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi +exit 0 diff --git a/tests/unit/renovate/dizzy_pin_custommanager_test.sh b/tests/unit/renovate/dizzy_pin_custommanager_test.sh new file mode 100644 index 000000000..3aaa0a633 --- /dev/null +++ b/tests/unit/renovate/dizzy_pin_custommanager_test.sh @@ -0,0 +1,149 @@ +#!/bin/bash +# SPDX-FileCopyrightText: Copyright 2026 SAP SE or an SAP affiliate company +# +# SPDX-License-Identifier: Apache-2.0 + +# Verify renovate.json tracks the single DIZZY_VERSION pin in hack/dizzy.sh +# and pairs it with the expected packageRules. +# +# Asserts: +# - exactly one customManager targets B42Labs/dizzy over hack/dizzy.sh with +# the github-releases datasource +# - its matchStrings regex captures the current pin from the real script and +# that capture equals the literal DIZZY_VERSION default in the file +# - a paired packageRule disables majors +# - a paired packageRule proposes minor/patch after a 3-day cooldown but +# never automerges — no CI lane exercises the WITH_DIZZY path, so a dizzy +# release that renames/relocates a staged dashboard must land under human +# review, not silently on main +# +# Usage: bash tests/unit/renovate/dizzy_pin_custommanager_test.sh + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" + +PASS=0 +FAIL=0 +SKIP=0 + +# shellcheck source=tests/lib/assertions.sh +source "$PROJECT_ROOT/tests/lib/assertions.sh" + +RENOVATE_FILE="$PROJECT_ROOT/renovate.json" +DIZZY_FILE="$PROJECT_ROOT/hack/dizzy.sh" + +PKG_NAME="B42Labs/dizzy" + +test_manager_exists_and_regex_matches() { + echo "Test: the DIZZY_VERSION pin has a customManager whose regex matches" + + if ! command -v jq >/dev/null 2>&1; then + echo " SKIP: jq not installed" + SKIP=$((SKIP + 1)) + return + fi + if ! command -v perl >/dev/null 2>&1; then + echo " SKIP: perl not installed" + SKIP=$((SKIP + 1)) + return + fi + if [[ ! -f "$DIZZY_FILE" ]]; then + echo " FAIL: $DIZZY_FILE missing" + FAIL=$((FAIL + 1)) + return + fi + + local count + count="$(jq --arg pkg "$PKG_NAME" '[.customManagers[] + | select(.packageNameTemplate == $pkg)] | length' "$RENOVATE_FILE")" + assert_eq "exactly one customManager targets ${PKG_NAME}" "1" "$count" + + local entry + entry="$(jq -c --arg pkg "$PKG_NAME" '.customManagers[] + | select(.packageNameTemplate == $pkg)' "$RENOVATE_FILE" | head -1)" + + if [ -z "$entry" ]; then + echo " FAIL: no customManager for ${PKG_NAME}" + FAIL=$((FAIL + 1)) + return + fi + + assert_contains "manager targets hack/dizzy.sh" \ + "$(jq -r '.managerFilePatterns[0]' <<<"$entry")" "dizzy" + assert_eq "manager uses the github-releases datasource" \ + "github-releases" "$(jq -r '.datasourceTemplate' <<<"$entry")" + + local match_string captured expected_pin + match_string="$(jq -r '.matchStrings[0]' <<<"$entry")" + captured="$(REGEX="$match_string" FILE="$DIZZY_FILE" perl -e ' + my $re = $ENV{REGEX}; + local $/; open my $fh, "<", $ENV{FILE} or die $!; + my $content = <$fh>; + if ($content =~ /$re/) { print $+{currentValue} // ""; } + ')" + + assert_not_empty "matchStrings regex captures the dizzy pin" "$captured" + + # Derive the expected pin straight from the script so a Renovate bump keeps + # this test green — never hard-code the version. + expected_pin="$(grep -oE 'DIZZY_VERSION:-v[0-9]+\.[0-9]+\.[0-9]+' "$DIZZY_FILE" \ + | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+' | head -1)" + assert_eq "captured pin equals the literal DIZZY_VERSION default" \ + "$expected_pin" "$captured" +} + +test_package_rules() { + echo "Test: paired packageRules gate dizzy major vs minor/patch updates" + + if ! command -v jq >/dev/null 2>&1; then + echo " SKIP: jq not installed (3 checks skipped)" + SKIP=$((SKIP + 3)) + return + fi + + local major_rule minor_rule + major_rule="$(jq -c '.packageRules[] + | select( + ((.matchFileNames // []) | index("hack/dizzy.sh")) != null + and (((.matchPackageNames // []) | index("B42Labs/dizzy")) != null) + and (((.matchUpdateTypes // []) | index("major")) != null) + )' "$RENOVATE_FILE" | head -1)" + + minor_rule="$(jq -c '.packageRules[] + | select( + ((.matchFileNames // []) | index("hack/dizzy.sh")) != null + and (((.matchPackageNames // []) | index("B42Labs/dizzy")) != null) + and (((.matchUpdateTypes // []) | index("minor")) != null) + )' "$RENOVATE_FILE" | head -1)" + + if [ -z "$major_rule" ]; then + echo " FAIL: no packageRule disabling majors for hack/dizzy.sh" + FAIL=$((FAIL + 1)) + else + assert_eq "major dizzy updates are disabled" \ + "false" "$(jq -r '.enabled' <<<"$major_rule")" + fi + + if [ -z "$minor_rule" ]; then + echo " FAIL: no packageRule for minor/patch dizzy updates" + FAIL=$((FAIL + 2)) + return + fi + + assert_eq "minor/patch dizzy updates are not automerged (no WITH_DIZZY CI gate)" \ + "false" "$(jq -r '.automerge' <<<"$minor_rule")" + assert_eq "minor/patch dizzy rule waits minimumReleaseAge=3 days" \ + "3 days" "$(jq -r '.minimumReleaseAge' <<<"$minor_rule")" +} + +test_manager_exists_and_regex_matches +test_package_rules + +echo "" +echo "Results: $PASS passed, $FAIL failed, $SKIP skipped" +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi +exit 0