From 7debcdc862f075d00661ba5e5db8f369d820b247 Mon Sep 17 00:00:00 2001 From: Predrag Knezevic Date: Thu, 2 Jul 2026 10:36:40 +0200 Subject: [PATCH] feat: replace asciinema with godog-based demo recording Convert terminal demos from standalone shell scripts recorded via asciinema to Godog BDD scenarios that generate asciicast v2 files programmatically. This makes demos into verified tests that can never silently break while producing terminal recordings. The recording infrastructure captures kubectl commands and their stdout/stderr with real execution timing during @demo-tagged scenario execution. Per-step dedup handles polling transparently so only successful results appear in the recording. Step text is emitted as comments and commands are colored for readability. Demo scenarios: - ClusterCatalog Quickstart: queries the operatorhubio catalog API - SingleNamespace Install Mode: installs mariadb-operator with a separate watch namespace, verifies rolebindings - OwnNamespace Install Mode: installs mariadb-operator with watch namespace equal to install namespace - Webhook Support: installs telegraf-operator, verifies mutating webhook injects sidecar container into test pod Not converted (no matching demo scenarios): - Synthetic User Permissions: feature gate disabled in experimental - Helm Chart Support: blocked by OCPBUGS-95281 The update-demos Makefile target now builds OLM from source using the experimental e2e infrastructure, deploys with all feature gates and operatorhubio catalog, runs demo scenarios, converts recordings to SVG via svg-term-cli, and tears down the cluster. Demo recordings are embedded in docs via the mkdocs-asciinema-player plugin and served from GitHub Pages, removing the dependency on asciinema.org for hosting recordings. Demo scenarios are excluded from regular e2e test runs via ~@demo filter. CI workflow (update-demos.yaml): - On PR: generates demos, uploads asciicast and SVG files as job artifacts - On merge to main: generates demos, deploys docs with updated recordings via make deploy-docs Old demo shell scripts, the asciinema generate-asciidemo.sh wrapper, and hack/demo/ resource files have been removed. Co-Authored-By: Claude --- .github/workflows/update-demos.yaml | 43 ++-- .gitignore | 3 + Makefile | 38 ++- README.md | 2 +- docs/draft/howto/enable-webhook-support.md | 6 +- .../howto/single-ownnamespace-install.md | 12 +- hack/demo/catalogd-demo-script.sh | 38 --- hack/demo/catalogd-metas-demo-script.sh | 44 ---- hack/demo/generate-asciidemo.sh | 91 -------- hack/demo/graphql-demo-script.sh | 124 ---------- hack/demo/graphql-demo-server/main.go | 112 --------- hack/demo/gzip-demo-script.sh | 29 --- hack/demo/own-namespace-demo-script.sh | 70 ------ hack/demo/resources/own-namespace-demo.yaml | 13 -- .../demo/resources/single-namespace-demo.yaml | 17 -- .../argocd-clusterextension.yaml | 13 -- .../cegroup-admin-binding.yaml | 11 - .../mutating-webhook-test.yaml | 7 - .../validating-webhook-test.yaml | 7 - .../webhook-operator-catalog.yaml | 9 - .../webhook-operator-extension.yaml | 15 -- hack/demo/single-namespace-demo-script.sh | 73 ------ ...ynthetic-user-cluster-admin-demo-script.sh | 30 --- ...ebhook-provider-certmanager-demo-script.sh | 60 ----- mkdocs.yml | 4 + requirements.txt | 1 + scripts/install.tpl.sh | 3 +- test/e2e/features/demos.feature | 113 +++++++++ test/e2e/features_test.go | 1 + test/e2e/steps/asciicast_hooks.go | 82 +++++++ test/e2e/steps/asciicast_recorder.go | 220 +++++++++++++++++ test/e2e/steps/demo_steps.go | 221 ++++++++++++++++++ test/e2e/steps/ha_steps.go | 4 +- test/e2e/steps/hooks.go | 15 +- test/e2e/steps/proxy_steps.go | 10 +- test/e2e/steps/steps.go | 203 ++++++++++------ test/e2e/steps/tls_steps.go | 23 +- test/e2e/steps/upgrade_steps.go | 45 ++-- 38 files changed, 920 insertions(+), 892 deletions(-) delete mode 100755 hack/demo/catalogd-demo-script.sh delete mode 100755 hack/demo/catalogd-metas-demo-script.sh delete mode 100755 hack/demo/generate-asciidemo.sh delete mode 100755 hack/demo/graphql-demo-script.sh delete mode 100644 hack/demo/graphql-demo-server/main.go delete mode 100755 hack/demo/gzip-demo-script.sh delete mode 100755 hack/demo/own-namespace-demo-script.sh delete mode 100644 hack/demo/resources/own-namespace-demo.yaml delete mode 100644 hack/demo/resources/single-namespace-demo.yaml delete mode 100644 hack/demo/resources/synthetic-user-perms/argocd-clusterextension.yaml delete mode 100644 hack/demo/resources/synthetic-user-perms/cegroup-admin-binding.yaml delete mode 100644 hack/demo/resources/webhook-provider-certmanager/mutating-webhook-test.yaml delete mode 100644 hack/demo/resources/webhook-provider-certmanager/validating-webhook-test.yaml delete mode 100644 hack/demo/resources/webhook-provider-certmanager/webhook-operator-catalog.yaml delete mode 100644 hack/demo/resources/webhook-provider-certmanager/webhook-operator-extension.yaml delete mode 100755 hack/demo/single-namespace-demo-script.sh delete mode 100755 hack/demo/synthetic-user-cluster-admin-demo-script.sh delete mode 100755 hack/demo/webhook-provider-certmanager-demo-script.sh create mode 100644 test/e2e/features/demos.feature create mode 100644 test/e2e/steps/asciicast_hooks.go create mode 100644 test/e2e/steps/asciicast_recorder.go create mode 100644 test/e2e/steps/demo_steps.go diff --git a/.github/workflows/update-demos.yaml b/.github/workflows/update-demos.yaml index cb3808bf2c..076624613b 100644 --- a/.github/workflows/update-demos.yaml +++ b/.github/workflows/update-demos.yaml @@ -1,39 +1,52 @@ name: update-demos on: - schedule: - - cron: '0 3 * * *' # Runs every day at 03:00 UTC workflow_dispatch: push: + branches: [main] paths: - 'api/*' - 'config/*' - - 'hack/demo/*' + - 'test/e2e/features/demos.feature' + - 'test/e2e/steps/demo_steps.go' + - 'test/e2e/steps/asciicast_*.go' - '.github/workflows/update-demos.yaml' pull_request: paths: - 'api/*' - 'config/*' - - 'hack/demo/*' + - 'test/e2e/features/demos.feature' + - 'test/e2e/steps/demo_steps.go' + - 'test/e2e/steps/asciicast_*.go' - '.github/workflows/update-demos.yaml' - + jobs: - demo: + generate-demos: runs-on: ubuntu-latest + permissions: + contents: ${{ github.event_name == 'push' && 'write' || 'read' }} env: TERM: linux steps: - - run: sudo apt update && sudo apt install -y asciinema curl - uses: actions/checkout@v7.0.0 - uses: actions/setup-go@v6.4.0 with: go-version-file: "go.mod" - - name: Run Demo Update - run: | - env -i \ - HOME="$HOME" \ - PATH="$PATH" \ - TERM="xterm-256color" \ - SHELL="/bin/bash" \ - make update-demos + - uses: actions/setup-python@v6.2.0 + with: + python-version: 3.x + cache: pip + - name: Generate demo recordings + run: make update-demos + + - uses: actions/upload-artifact@v7 + if: always() + with: + name: demo-recordings + path: | + docs/demos/*.cast + docs/demos/*.svg + - name: Deploy docs with updated demos + if: github.event_name == 'push' + run: make deploy-docs diff --git a/.gitignore b/.gitignore index 3748a581e0..afdfe0c79c 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,9 @@ vendor/ # documentation website asset folder site +# generated demo asciicast recordings +docs/demos/ + .tiltbuild/ .catalogd-tmp/ .vscode diff --git a/Makefile b/Makefile index 7d9913b8b6..8ef54e8fd5 100644 --- a/Makefile +++ b/Makefile @@ -413,11 +413,11 @@ ifeq ($(strip $(GODOG_ARGS)),) set +e; \ KUBECONFIG=$(E2E_KUBECONFIG) \ PROMETHEUS_URL=http://localhost:$$E2E_PROMETHEUS_PORT \ - go test -count=1 -v ./test/e2e/features_test.go -timeout $(or $(E2E_TIMEOUT),20m) -args --godog.tags="~@Serial" --godog.concurrency=100; \ + go test -count=1 -v ./test/e2e/features_test.go -timeout $(or $(E2E_TIMEOUT),20m) -args --godog.tags="~@Serial && ~@demo" --godog.concurrency=100; \ parallelExit=$$?; \ KUBECONFIG=$(E2E_KUBECONFIG) \ PROMETHEUS_URL=http://localhost:$$E2E_PROMETHEUS_PORT \ - go test -count=1 -v ./test/e2e/features_test.go -timeout $(or $(E2E_TIMEOUT),20m) -args --godog.tags="@Serial" --godog.concurrency=1; \ + go test -count=1 -v ./test/e2e/features_test.go -timeout $(or $(E2E_TIMEOUT),20m) -args --godog.tags="@Serial && ~@demo" --godog.concurrency=1; \ serialExit=$$?; \ if [[ $$parallelExit -ne 0 ]] || [[ $$serialExit -ne 0 ]]; then \ echo "e2e tests failed: parallel=$$parallelExit serial=$$serialExit"; \ @@ -693,13 +693,33 @@ deploy-docs: venv . $(VENV)/activate; \ mkdocs gh-deploy --force --strict -# The demo script requires to install asciinema with: brew install asciinema to run on mac os envs. -# Please ensure that all demos are named with the demo name and the suffix -demo-script.sh -.PHONY: update-demos #EXHELP Validate demo recordings. -update-demos: - @for script in hack/demo/*-demo-script.sh; do \ - nm=$$(basename $$script -script.sh); \ - ./hack/demo/generate-asciidemo.sh -n $$nm $$(basename $$script); \ +DEMO_OUTPUT_DIR ?= $(ROOT_DIR)/docs/demos + +.PHONY: update-demos +update-demos: SOURCE_MANIFEST := $(EXPERIMENTAL_E2E_MANIFEST) +update-demos: export MANIFEST := $(EXPERIMENTAL_RELEASE_MANIFEST) +update-demos: export DEFAULT_CATALOG := $(CATALOGS_MANIFEST) +update-demos: export INSTALL_DEFAULT_CATALOGS := true +update-demos: export CATALOG_WAIT_TIMEOUT := 5m +update-demos: wait-operator-controller-experimental-e2e demo-e2e demo-svg experimental-e2e-teardown #EXHELP Record demo scenarios as asciicast and SVG files. + +.PHONY: demo-e2e +demo-e2e: + @command -v curl >/dev/null 2>&1 || { echo "Error: curl not found in PATH."; exit 1; } + @command -v jq >/dev/null 2>&1 || { echo "Error: jq not found in PATH."; exit 1; } + @mkdir -p $(DEMO_OUTPUT_DIR) + KUBECONFIG=$(KUBECONFIG_DIR)/operator-controller-experimental-e2e.kubeconfig \ + DEMO_OUTPUT_DIR=$(DEMO_OUTPUT_DIR) go test -count=1 -v ./test/e2e/features_test.go -timeout 30m \ + -args --godog.tags="@demo" --godog.concurrency=1 + +.PHONY: demo-svg +demo-svg: #EXHELP Convert asciicast recordings to SVG. + @command -v docker >/dev/null 2>&1 || { echo "Error: docker not found in PATH."; exit 1; } + @for cast in $(DEMO_OUTPUT_DIR)/*.cast; do \ + svg=$${cast%.cast}.svg; \ + echo "Converting $$(basename $$cast) -> $$(basename $$svg)"; \ + docker run --rm -v $(DEMO_OUTPUT_DIR):/data node:alpine \ + npx --yes svg-term-cli --in /data/$$(basename $$cast) --out /data/$$(basename $$svg) --window; \ done include Makefile.venv diff --git a/README.md b/README.md index 4be7f30d08..98597901a2 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ To get started with OLM v1, please see our [Getting Started](https://operator-fr ### Quickstart DEMO -[![asciicast](https://asciinema.org/a/682344.svg)](https://asciinema.org/a/682344) +[![ClusterCatalog Demo](https://operator-framework.github.io/operator-controller/demos/clustercatalog-quickstart.svg)](https://operator-framework.github.io/operator-controller/) ### ClusterCatalog Quickstart Steps diff --git a/docs/draft/howto/enable-webhook-support.md b/docs/draft/howto/enable-webhook-support.md index f5c7de7767..cadfa42fc4 100644 --- a/docs/draft/howto/enable-webhook-support.md +++ b/docs/draft/howto/enable-webhook-support.md @@ -52,4 +52,8 @@ There's no change in the installation flow. Just install a bundle containing web As there is no difference in usage or experience between the CertManager and Openshift-ServiceCA variants, only the cert-manager variant is demoed. -[![asciicast](https://asciinema.org/a/GyjsB129GkUadeuxFhNuG4FcS.svg)](https://asciinema.org/a/GyjsB129GkUadeuxFhNuG4FcS) +```asciinema-player +{ + "file": "../../../demos/webhook-support.cast" +} +``` diff --git a/docs/draft/howto/single-ownnamespace-install.md b/docs/draft/howto/single-ownnamespace-install.md index e2ad3efebe..ebcb2b95a2 100644 --- a/docs/draft/howto/single-ownnamespace-install.md +++ b/docs/draft/howto/single-ownnamespace-install.md @@ -25,11 +25,19 @@ include *installModes*. ### SingleNamespace Install -[![SingleNamespace Install Demo](https://asciinema.org/a/w1IW0xWi1S9cKQFb9jnR07mgh.svg)](https://asciinema.org/a/w1IW0xWi1S9cKQFb9jnR07mgh) +```asciinema-player +{ + "file": "../../../demos/singlenamespace-install-mode.cast" +} +``` ### OwnNamespace Install -[![OwnNamespace Install Demo](https://asciinema.org/a/Rxx6WUwAU016bXFDW74XLcM5i.svg)](https://asciinema.org/a/Rxx6WUwAU016bXFDW74XLcM5i) +```asciinema-player +{ + "file": "../../../demos/ownnamespace-install-mode.cast" +} +``` ## Enabling the Feature-Gate diff --git a/hack/demo/catalogd-demo-script.sh b/hack/demo/catalogd-demo-script.sh deleted file mode 100755 index e7f226f24f..0000000000 --- a/hack/demo/catalogd-demo-script.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env bash - -# -# Welcome to the catalogd demo -# -trap "trap - SIGTERM && kill -- -$$" SIGINT SIGTERM EXIT - - -kind delete cluster -kind create cluster -kubectl cluster-info --context kind-kind -sleep 10 - -# use the install script from the latest github release -curl -L -s https://github.com/operator-framework/operator-controller/releases/latest/download/install.sh | bash - -# inspect crds (clustercatalog) -kubectl get crds -A -kubectl get clustercatalog -A - -echo "... checking catalogd controller is available" -kubectl wait --for=condition=Available -n olmv1-system deploy/catalogd-controller-manager --timeout=1m -echo "... checking clustercatalog is serving" -kubectl wait --for=condition=Serving clustercatalog/operatorhubio --timeout=60s -echo "... checking clustercatalog is finished unpacking" -kubectl wait --for=condition=Progressing=True clustercatalog/operatorhubio --timeout=60s - -# port forward the catalogd-service service to interact with the HTTP server serving catalog contents -(kubectl -n olmv1-system port-forward svc/catalogd-service 8081:443)& - -sleep 3 - -# check what 'packages' are available in this catalog -curl -k https://localhost:8081/catalogs/operatorhubio/api/v1/all | jq -s '.[] | select(.schema == "olm.package") | .name' -# check what channels are included in the wavefront package -curl -k https://localhost:8081/catalogs/operatorhubio/api/v1/all | jq -s '.[] | select(.schema == "olm.channel") | select(.package == "wavefront") | .name' -# check what bundles are included in the wavefront package -curl -k https://localhost:8081/catalogs/operatorhubio/api/v1/all | jq -s '.[] | select(.schema == "olm.bundle") | select(.package == "wavefront") | .name' diff --git a/hack/demo/catalogd-metas-demo-script.sh b/hack/demo/catalogd-metas-demo-script.sh deleted file mode 100755 index 63fb84b838..0000000000 --- a/hack/demo/catalogd-metas-demo-script.sh +++ /dev/null @@ -1,44 +0,0 @@ -#!/usr/bin/env bash -# -# Welcome to the catalogd metas API endpoint demo -# -trap 'trap - SIGTERM && kill -- -"$$"' SIGINT SIGTERM EXIT - -kind delete cluster -kind create cluster -kubectl cluster-info --context kind-kind -sleep 10 - -# use the install script from the latest github release -curl -L -s https://github.com/operator-framework/operator-controller/releases/latest/download/install.sh | bash - -# inspect crds (clustercatalog) -kubectl get crds -A -kubectl get clustercatalog -A - -# ... checking catalogd controller is available -kubectl wait --for=condition=Available -n olmv1-system deploy/catalogd-controller-manager --timeout=1m - -# patch the deployment to include the feature gate -kubectl patch -n olmv1-system deploy/catalogd-controller-manager --type='json' -p='[{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value": "--feature-gates=APIV1MetasHandler=true"}]' - -# ... waiting for new deployment for catalogd controller to become available -kubectl rollout status -n olmv1-system deploy/catalogd-controller-manager -kubectl wait --for=condition=Available -n olmv1-system deploy/catalogd-controller-manager --timeout=1m -# ... checking clustercatalog is serving -kubectl wait --for=condition=Serving clustercatalog/operatorhubio --timeout=60s -# ... checking clustercatalog is finished unpacking (progressing gone back to true) -kubectl wait --for=condition=Progressing=True clustercatalog/operatorhubio --timeout=60s - - -# port forward the catalogd-service service to interact with the HTTP server serving catalog contents -(kubectl -n olmv1-system port-forward svc/catalogd-service 8081:443)& - - -# check what 'packages' are available in this catalog -curl -f --retry-all-errors --retry 10 -k 'https://localhost:8081/catalogs/operatorhubio/api/v1/metas?schema=olm.package' | jq -s '.[] | .name' -# check what channels are included in the wavefront package -curl -f -k 'https://localhost:8081/catalogs/operatorhubio/api/v1/metas?schema=olm.channel&package=wavefront' | jq -s '.[] |.name' -# check what bundles are included in the wavefront package -curl -f -k 'https://localhost:8081/catalogs/operatorhubio/api/v1/metas?schema=olm.bundle&package=wavefront' | jq -s '.[] |.name' - diff --git a/hack/demo/generate-asciidemo.sh b/hack/demo/generate-asciidemo.sh deleted file mode 100755 index 737c230884..0000000000 --- a/hack/demo/generate-asciidemo.sh +++ /dev/null @@ -1,91 +0,0 @@ -#!/usr/bin/env bash - -trap cleanup SIGINT SIGTERM EXIT - -SCRIPTPATH="$( cd -- "$(dirname "$0")" > /dev/null 2>&1 ; pwd -P )" -export DEMO_RESOURCE_DIR="${SCRIPTPATH}/resources" - -check_prereq() { - prog=$1 - if ! command -v ${prog} &> /dev/null - then - echo "unable to find prerequisite: $1" - exit 1 - fi -} - -cleanup() { - if [[ -n "${WKDIR-}" && -d $WKDIR ]]; then - rm -rf $WKDIR - fi -} - -usage() { - echo "$0 [options] " - echo "" - echo "options:" - echo " -n " - echo " -u upload cast (default: false)" - echo " -h help (this message)" - echo "" - echo "examples:" - echo " # Generate asciinema demo described by gzip-demo-script.sh into gzip-demo-script.cast" - echo " $0 gzip-demo-script.sh" - echo "" - echo " # Generate asciinema demo described by demo-script.sh into catalogd-demo.cast" - echo " $0 -n catalogd-demo demo-script.sh" - echo "" - echo " # Generate and upload catalogd-demo.cast" - echo " $0 -u -n catalogd-demo demo-script.sh" - exit 1 -} - -set +u -while getopts ':hn:u' flag; do - case "${flag}" in - h) - usage - ;; - n) - DEMO_NAME="${OPTARG}" - ;; - u) - UPLOAD=true - ;; - :) - echo "Error: Option -${OPTARG} requires an argument." - usage - ;; - \?) - echo "Error: Invalid option -${OPTARG}" - usage - ;; - esac -done -shift $((OPTIND - 1)) -set -u - -DEMO_SCRIPT="${1-}" - -if [ -z $DEMO_SCRIPT ]; then - usage -fi - -WKDIR=$(mktemp -d -t generate-asciidemo.XXXXX) -if [ ! -d ${WKDIR} ]; then - echo "unable to create temporary workspace" - exit 2 -fi - -for prereq in "asciinema curl"; do - check_prereq ${prereq} -done - -curl https://raw.githubusercontent.com/zechris/asciinema-rec_script/main/bin/asciinema-rec_script -o ${WKDIR}/asciinema-rec_script -chmod +x ${WKDIR}/asciinema-rec_script -screencast=${WKDIR}/${DEMO_NAME}.cast ${WKDIR}/asciinema-rec_script ${SCRIPTPATH}/${DEMO_SCRIPT} - -if [ -n "${UPLOAD-}" ]; then - asciinema upload ${WKDIR}/${DEMO_NAME}.cast -fi - diff --git a/hack/demo/graphql-demo-script.sh b/hack/demo/graphql-demo-script.sh deleted file mode 100755 index 16ccf1f6a0..0000000000 --- a/hack/demo/graphql-demo-script.sh +++ /dev/null @@ -1,124 +0,0 @@ -#!/usr/bin/env bash - -# -# Catalogd Dynamic GraphQL API Demo -# -# This demo showcases the dynamic GraphQL endpoint for querying -# File-Based Catalog (FBC) content. The schema is automatically -# discovered from catalog data -- no manual type definitions needed. -# - -set -euo pipefail -trap cleanup SIGINT SIGTERM EXIT - -SCRIPTPATH="$(cd -- "$(dirname "$0")" > /dev/null 2>&1; pwd -P)" -SERVER_PID="" -PORT=9376 -BASE="http://localhost:${PORT}/catalogs/example-catalog/api/v1/graphql" - -cleanup() { - if [[ -n "${SERVER_PID}" ]]; then - kill "${SERVER_PID}" 2>/dev/null || true - wait "${SERVER_PID}" 2>/dev/null || true - fi - if [[ -n "${TMPBIN:-}" && -f "${TMPBIN}" ]]; then - rm -f "${TMPBIN}" - fi -} - -gql() { - local query="$1" - curl -s -X POST "${BASE}" \ - -H "Content-Type: application/json" \ - -d "{\"query\": \"$query\"}" | jq . -} - -banner() { - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo " $1" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -} - -# -- Build and start the demo server -- -banner "Building GraphQL demo server" -TMPBIN=$(mktemp /tmp/graphql-demo.XXXXXX) -REPOROOT="$(cd "${SCRIPTPATH}/../.."; pwd)" -(cd "${REPOROOT}" && go build -o "${TMPBIN}" ./hack/demo/graphql-demo-server/) -echo "Built successfully." - -echo "" -echo "Starting server on port ${PORT}..." -"${TMPBIN}" 2>/dev/null & -SERVER_PID=$! -sleep 1 -echo "Server ready. Catalog loaded: example-catalog (5 packages, 11 bundles)" - -# -- 1. Catalog Summary -- -banner "1. Discover catalog contents (summary query)" -echo '$ curl ... -d '\''{"query": "{ summary { totalSchemas schemas { name totalObjects totalFields } } }"}'\''' -echo "" -gql "{ summary { totalSchemas schemas { name totalObjects totalFields } } }" -sleep 2 - -# -- 2. List packages -- -banner "2. List packages (field selection: name + defaultChannel only)" -echo '$ curl ... -d '\''{"query": "{ olmpackages { name defaultChannel } }"}'\''' -echo "" -gql "{ olmpackages { name defaultChannel } }" -sleep 2 - -# -- 3. List bundles with pagination -- -banner "3. Browse bundles with pagination (offset 3, limit 4)" -echo '$ curl ... -d '\''{"query": "{ olmbundles(limit: 4, offset: 3) { name package } }"}'\''' -echo "" -gql "{ olmbundles(limit: 4, offset: 3) { name package } }" -sleep 2 - -# -- 4. Nested properties -- -banner "4. Query bundle properties (nested objects)" -echo '$ curl ... -d '\''{"query": "{ olmbundles(limit: 2) { name package properties { type value } } }"}'\''' -echo "" -gql "{ olmbundles(limit: 2) { name package properties { type value } } }" -sleep 2 - -# -- 5. Related images -- -banner "5. Query related images (nested array)" -echo '$ curl ... -d '\''{"query": "{ olmbundles(limit: 1) { name relatedImages { name image } } }"}'\''' -echo "" -gql "{ olmbundles(limit: 1) { name relatedImages { name image } } }" -sleep 2 - -# -- 6. Channel upgrade graph -- -banner "6. Explore channel upgrade graph" -echo '$ curl ... -d '\''{"query": "{ olmchannels(limit: 2) { name package entries { name skipRange } } }"}'\''' -echo "" -gql "{ olmchannels(limit: 2) { name package entries { name skipRange } } }" -sleep 2 - -# -- 7. Cross-schema query -- -banner "7. Multi-schema query in one request" -echo '$ curl ... -d '\''{"query": "{ olmpackages(limit: 3) { name defaultChannel } olmbundles(limit: 3) { name package } }"}'\''' -echo "" -gql "{ olmpackages(limit: 3) { name defaultChannel } olmbundles(limit: 3) { name package } }" -sleep 2 - -# -- 8. GraphQL introspection -- -banner "8. Standard GraphQL introspection (auto-generated types)" -echo '$ curl ... -d '\''{"query": "{ __schema { types { name kind } } }"}'\'' | jq ...' -echo "" -curl -s -X POST "${BASE}" \ - -H "Content-Type: application/json" \ - -d '{"query": "{ __schema { types { name kind } } }"}' \ - | jq '[.data.__schema.types[] | select(.name | startswith("__") | not) | select(.kind == "OBJECT")]' -sleep 2 - -# -- Done -- -banner "Demo complete" -echo "" -echo "Key takeaways:" -echo " - GraphQL schema is auto-discovered from FBC data" -echo " - No code changes needed when FBC schemas evolve" -echo " - Supports field selection, pagination, nested queries" -echo " - Full GraphQL introspection for tooling support" -echo "" diff --git a/hack/demo/graphql-demo-server/main.go b/hack/demo/graphql-demo-server/main.go deleted file mode 100644 index 7356ce64dd..0000000000 --- a/hack/demo/graphql-demo-server/main.go +++ /dev/null @@ -1,112 +0,0 @@ -package main - -import ( - "fmt" - "io/fs" - "net/http" - "net/url" - "os" - "os/signal" - "syscall" - "testing/fstest" - - "github.com/operator-framework/operator-controller/internal/catalogd/server" - "github.com/operator-framework/operator-controller/internal/catalogd/service" -) - -// demoCatalogStore implements server.CatalogStore backed by an in-memory FS -type demoCatalogStore struct { - catalogs map[string]fs.FS -} - -func (s *demoCatalogStore) GetCatalogData(catalog string) (*os.File, os.FileInfo, error) { - return nil, nil, fmt.Errorf("not implemented for demo") -} - -func (s *demoCatalogStore) GetCatalogFS(catalog string) (fs.FS, error) { - catFS, ok := s.catalogs[catalog] - if !ok { - return nil, fs.ErrNotExist - } - return catFS, nil -} - -func (s *demoCatalogStore) GetIndex(catalog string) (server.Index, error) { - return nil, fmt.Errorf("not implemented for demo") -} - -func main() { - addr := ":9376" - if v := os.Getenv("PORT"); v != "" { - addr = ":" + v - } - - store := &demoCatalogStore{ - catalogs: map[string]fs.FS{ - "example-catalog": buildCatalog(), - }, - } - - graphqlSvc := service.NewCachedGraphQLService() - rootURL, _ := url.Parse("/catalogs/") - handlers := server.NewCatalogHandlers( - store, - graphqlSvc, - rootURL, - server.MetasHandlerDisabled, - server.GraphQLQueriesEnabled, - ) - - mux := http.NewServeMux() - mux.Handle("/catalogs/", handlers.Handler()) - - fmt.Fprintf(os.Stderr, "GraphQL demo server listening on http://localhost%s\n", addr) - fmt.Fprintf(os.Stderr, "Endpoint: POST http://localhost%s/catalogs/example-catalog/api/v1/graphql\n", addr) - fmt.Fprintf(os.Stderr, "Press Ctrl+C to stop.\n") - - go func() { - // nolint:gosec - if err := http.ListenAndServe(addr, mux); err != nil { - fmt.Fprintf(os.Stderr, "server error: %v\n", err) - os.Exit(1) - } - }() - - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) - <-sigCh - fmt.Fprintln(os.Stderr, "\nShutting down.") -} - -func buildCatalog() fs.FS { - return fstest.MapFS{ - "catalog.json": &fstest.MapFile{ - Data: []byte(catalogJSON), - }, - } -} - -// catalogJSON contains sample FBC data for demonstration purposes. -// Each line is a separate JSON object (JSONL format parsed by WalkMetasFS). -const catalogJSON = `{"schema":"olm.package","name":"database-operator","defaultChannel":"stable","description":"An operator for managing database instances."} -{"schema":"olm.package","name":"logging-operator","defaultChannel":"stable","description":"Logging operator for collecting and forwarding application logs."} -{"schema":"olm.package","name":"messaging-operator","defaultChannel":"stable","description":"Messaging broker operator based on Apache Kafka."} -{"schema":"olm.package","name":"cicd-operator","defaultChannel":"latest","description":"CI/CD pipeline operator for cloud-native workflows."} -{"schema":"olm.package","name":"mesh-operator","defaultChannel":"stable","description":"Service mesh operator for microservices networking."} -{"schema":"olm.channel","name":"stable","package":"database-operator","entries":[{"name":"database-operator.v1.6.0","skipRange":">=0.1.17 <1.6.0"},{"name":"database-operator.v1.7.0","replaces":"database-operator.v1.6.0","skipRange":">=0.1.17 <1.7.0"},{"name":"database-operator.v1.7.1","replaces":"database-operator.v1.7.0","skipRange":">=1.0.0 <1.7.1"}]} -{"schema":"olm.channel","name":"stable","package":"logging-operator","entries":[{"name":"logging-operator.v5.8.10","skipRange":">=5.6.0 <5.8.10"},{"name":"logging-operator.v5.8.14","replaces":"logging-operator.v5.8.10","skipRange":">=5.6.0 <5.8.14"}]} -{"schema":"olm.channel","name":"stable","package":"messaging-operator","entries":[{"name":"messaging-operator.v2.7.0","skipRange":">=2.0.0 <2.7.0"},{"name":"messaging-operator.v2.8.0","replaces":"messaging-operator.v2.7.0","skipRange":">=2.0.0 <2.8.0"}]} -{"schema":"olm.channel","name":"latest","package":"cicd-operator","entries":[{"name":"cicd-operator.v1.14.4","skipRange":">=1.5.0 <1.14.4"},{"name":"cicd-operator.v1.15.1","replaces":"cicd-operator.v1.14.4","skipRange":">=1.5.0 <1.15.1"}]} -{"schema":"olm.channel","name":"stable","package":"mesh-operator","entries":[{"name":"mesh-operator.v2.5.2","skipRange":">=2.0.0 <2.5.2"},{"name":"mesh-operator.v2.6.2","replaces":"mesh-operator.v2.5.2","skipRange":">=2.0.0 <2.6.2"}]} -{"schema":"olm.bundle","name":"database-operator.v1.6.0","package":"database-operator","image":"quay.io/example/database-operator@sha256:aaa111","properties":[{"type":"olm.package","value":{"packageName":"database-operator","version":"1.6.0"}},{"type":"olm.gvk","value":{"group":"db.example.io","version":"v1alpha1","kind":"Database"}},{"type":"olm.gvk","value":{"group":"db.example.io","version":"v1alpha1","kind":"DatabaseBackup"}},{"type":"olm.gvk","value":{"group":"db.example.io","version":"v1alpha1","kind":"DatabaseRestore"}}],"relatedImages":[{"name":"operator","image":"quay.io/example/database-operator@sha256:aaa111"},{"name":"database","image":"quay.io/example/database@sha256:bbb222"}]} -{"schema":"olm.bundle","name":"database-operator.v1.7.0","package":"database-operator","image":"quay.io/example/database-operator@sha256:ccc333","replaces":"database-operator.v1.6.0","properties":[{"type":"olm.package","value":{"packageName":"database-operator","version":"1.7.0"}},{"type":"olm.gvk","value":{"group":"db.example.io","version":"v1alpha1","kind":"Database"}},{"type":"olm.gvk","value":{"group":"db.example.io","version":"v1alpha1","kind":"DatabaseBackup"}},{"type":"olm.gvk","value":{"group":"db.example.io","version":"v1alpha1","kind":"DatabaseRestore"}},{"type":"olm.gvk","value":{"group":"db.example.io","version":"v1alpha1","kind":"DatabaseSnapshot"}}],"relatedImages":[{"name":"operator","image":"quay.io/example/database-operator@sha256:ccc333"},{"name":"database","image":"quay.io/example/database@sha256:ddd444"}]} -{"schema":"olm.bundle","name":"database-operator.v1.7.1","package":"database-operator","image":"quay.io/example/database-operator@sha256:eee555","replaces":"database-operator.v1.7.0","properties":[{"type":"olm.package","value":{"packageName":"database-operator","version":"1.7.1"}},{"type":"olm.gvk","value":{"group":"db.example.io","version":"v1alpha1","kind":"Database"}},{"type":"olm.gvk","value":{"group":"db.example.io","version":"v1alpha1","kind":"DatabaseBackup"}},{"type":"olm.gvk","value":{"group":"db.example.io","version":"v1alpha1","kind":"DatabaseRestore"}},{"type":"olm.gvk","value":{"group":"db.example.io","version":"v1alpha1","kind":"DatabaseSnapshot"}}],"relatedImages":[{"name":"operator","image":"quay.io/example/database-operator@sha256:eee555"},{"name":"database","image":"quay.io/example/database@sha256:fff666"}]} -{"schema":"olm.bundle","name":"logging-operator.v5.8.10","package":"logging-operator","image":"quay.io/example/logging-operator@sha256:111aaa","properties":[{"type":"olm.package","value":{"packageName":"logging-operator","version":"5.8.10"}},{"type":"olm.gvk","value":{"group":"logging.example.io","version":"v1","kind":"LogCollector"}},{"type":"olm.gvk","value":{"group":"logging.example.io","version":"v1","kind":"LogForwarder"}}],"relatedImages":[{"name":"operator","image":"quay.io/example/logging-operator@sha256:111aaa"},{"name":"collector","image":"quay.io/example/log-collector@sha256:222bbb"},{"name":"forwarder","image":"quay.io/example/log-forwarder@sha256:333ccc"}]} -{"schema":"olm.bundle","name":"logging-operator.v5.8.14","package":"logging-operator","image":"quay.io/example/logging-operator@sha256:444ddd","replaces":"logging-operator.v5.8.10","properties":[{"type":"olm.package","value":{"packageName":"logging-operator","version":"5.8.14"}},{"type":"olm.gvk","value":{"group":"logging.example.io","version":"v1","kind":"LogCollector"}},{"type":"olm.gvk","value":{"group":"logging.example.io","version":"v1","kind":"LogForwarder"}}],"relatedImages":[{"name":"operator","image":"quay.io/example/logging-operator@sha256:444ddd"},{"name":"collector","image":"quay.io/example/log-collector@sha256:555eee"},{"name":"forwarder","image":"quay.io/example/log-forwarder@sha256:666fff"}]} -{"schema":"olm.bundle","name":"messaging-operator.v2.7.0","package":"messaging-operator","image":"quay.io/example/messaging-operator@sha256:777aaa","properties":[{"type":"olm.package","value":{"packageName":"messaging-operator","version":"2.7.0"}},{"type":"olm.gvk","value":{"group":"kafka.example.io","version":"v1beta2","kind":"Kafka"}},{"type":"olm.gvk","value":{"group":"kafka.example.io","version":"v1beta2","kind":"KafkaTopic"}},{"type":"olm.gvk","value":{"group":"kafka.example.io","version":"v1beta2","kind":"KafkaConnect"}},{"type":"olm.gvk","value":{"group":"kafka.example.io","version":"v1beta2","kind":"KafkaBridge"}}],"relatedImages":[{"name":"operator","image":"quay.io/example/messaging-operator@sha256:777aaa"},{"name":"kafka","image":"quay.io/example/kafka@sha256:888bbb"}]} -{"schema":"olm.bundle","name":"messaging-operator.v2.8.0","package":"messaging-operator","image":"quay.io/example/messaging-operator@sha256:999ccc","replaces":"messaging-operator.v2.7.0","properties":[{"type":"olm.package","value":{"packageName":"messaging-operator","version":"2.8.0"}},{"type":"olm.gvk","value":{"group":"kafka.example.io","version":"v1beta2","kind":"Kafka"}},{"type":"olm.gvk","value":{"group":"kafka.example.io","version":"v1beta2","kind":"KafkaTopic"}},{"type":"olm.gvk","value":{"group":"kafka.example.io","version":"v1beta2","kind":"KafkaConnect"}},{"type":"olm.gvk","value":{"group":"kafka.example.io","version":"v1beta2","kind":"KafkaBridge"}},{"type":"olm.gvk","value":{"group":"kafka.example.io","version":"v1beta2","kind":"KafkaMirrorMaker"}}],"relatedImages":[{"name":"operator","image":"quay.io/example/messaging-operator@sha256:999ccc"},{"name":"kafka","image":"quay.io/example/kafka@sha256:aaabbb"}]} -{"schema":"olm.bundle","name":"cicd-operator.v1.14.4","package":"cicd-operator","image":"quay.io/example/cicd-operator@sha256:bbbccc","properties":[{"type":"olm.package","value":{"packageName":"cicd-operator","version":"1.14.4"}},{"type":"olm.gvk","value":{"group":"tekton.example.io","version":"v1alpha1","kind":"Pipeline"}},{"type":"olm.gvk","value":{"group":"tekton.example.io","version":"v1alpha1","kind":"PipelineRun"}}],"relatedImages":[{"name":"operator","image":"quay.io/example/cicd-operator@sha256:bbbccc"},{"name":"controller","image":"quay.io/example/pipeline-controller@sha256:cccddd"}]} -{"schema":"olm.bundle","name":"cicd-operator.v1.15.1","package":"cicd-operator","image":"quay.io/example/cicd-operator@sha256:dddeee","replaces":"cicd-operator.v1.14.4","properties":[{"type":"olm.package","value":{"packageName":"cicd-operator","version":"1.15.1"}},{"type":"olm.gvk","value":{"group":"tekton.example.io","version":"v1alpha1","kind":"Pipeline"}},{"type":"olm.gvk","value":{"group":"tekton.example.io","version":"v1alpha1","kind":"PipelineRun"}},{"type":"olm.gvk","value":{"group":"tekton.example.io","version":"v1alpha1","kind":"Task"}}],"relatedImages":[{"name":"operator","image":"quay.io/example/cicd-operator@sha256:dddeee"},{"name":"controller","image":"quay.io/example/pipeline-controller@sha256:eeefff"}]} -{"schema":"olm.bundle","name":"mesh-operator.v2.5.2","package":"mesh-operator","image":"quay.io/example/mesh-operator@sha256:fff111","properties":[{"type":"olm.package","value":{"packageName":"mesh-operator","version":"2.5.2"}},{"type":"olm.gvk","value":{"group":"mesh.example.io","version":"v1","kind":"ServiceMesh"}},{"type":"olm.gvk","value":{"group":"mesh.example.io","version":"v1","kind":"Gateway"}}],"relatedImages":[{"name":"operator","image":"quay.io/example/mesh-operator@sha256:fff111"},{"name":"proxy","image":"quay.io/example/mesh-proxy@sha256:111222"},{"name":"sidecar","image":"quay.io/example/mesh-sidecar@sha256:222333"}]} -{"schema":"olm.bundle","name":"mesh-operator.v2.6.2","package":"mesh-operator","image":"quay.io/example/mesh-operator@sha256:333444","replaces":"mesh-operator.v2.5.2","properties":[{"type":"olm.package","value":{"packageName":"mesh-operator","version":"2.6.2"}},{"type":"olm.gvk","value":{"group":"mesh.example.io","version":"v1","kind":"ServiceMesh"}},{"type":"olm.gvk","value":{"group":"mesh.example.io","version":"v1","kind":"Gateway"}},{"type":"olm.gvk","value":{"group":"mesh.example.io","version":"v1","kind":"VirtualService"}}],"relatedImages":[{"name":"operator","image":"quay.io/example/mesh-operator@sha256:333444"},{"name":"proxy","image":"quay.io/example/mesh-proxy@sha256:444555"},{"name":"sidecar","image":"quay.io/example/mesh-sidecar@sha256:555666"}]} -` diff --git a/hack/demo/gzip-demo-script.sh b/hack/demo/gzip-demo-script.sh deleted file mode 100755 index 2cd1bb7946..0000000000 --- a/hack/demo/gzip-demo-script.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env bash - -trap "trap - SIGTERM && kill -- -$$" SIGINT SIGTERM EXIT -# Welcome to the catalogd demo -make run - -# create a clustercatalog -kubectl apply -f $HOME/devel/tmp/operatorhubio-clustercatalog.yaml -# shows catalog -kubectl get clustercatalog -A -# waiting for clustercatalog to report ready status -time kubectl wait --for=condition=Unpacked clustercatalog/operatorhubio --timeout=1m - -# port forward the catalogd-service service to interact with the HTTP server serving catalog contents -(kubectl -n olmv1-system port-forward svc/catalogd-service 8080:443)& -sleep 5 - -# retrieve catalog as plaintext JSONlines -curl -k -vvv https://localhost:8080/catalogs/operatorhubio/api/v1/all --output /tmp/cat-content.json - -# advertise handling of compressed content -curl -vvv -k https://localhost:8080/catalogs/operatorhubio/api/v1/all -H 'Accept-Encoding: gzip' --output /tmp/cat-content.gz - -# let curl handle the compress/decompress for us -curl -vvv --compressed -k https://localhost:8080/catalogs/operatorhubio/api/v1/all --output /tmp/cat-content-decompressed.txt - -# show that there's no content change with changed format -diff /tmp/cat-content.json /tmp/cat-content-decompressed.txt - diff --git a/hack/demo/own-namespace-demo-script.sh b/hack/demo/own-namespace-demo-script.sh deleted file mode 100755 index c83c0eb0c4..0000000000 --- a/hack/demo/own-namespace-demo-script.sh +++ /dev/null @@ -1,70 +0,0 @@ -#!/usr/bin/env bash - -# -# Welcome to the OwnNamespace install mode demo -# -set -e -trap 'echo "Demo ran into error"; trap - SIGTERM && kill -- -$$; exit 1' ERR SIGINT SIGTERM EXIT - -# install experimental CRDs with config field support -kubectl apply -f "$(dirname "${BASH_SOURCE[0]}")/../../manifests/experimental.yaml" - -# wait for experimental CRDs to be available -kubectl wait --for condition=established --timeout=60s crd/clusterextensions.olm.operatorframework.io - -# enable 'SingleOwnNamespaceInstallSupport' feature gate -kubectl patch deployment -n olmv1-system operator-controller-controller-manager --type='json' -p='[{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value": "--feature-gates=SingleOwnNamespaceInstallSupport=true"}]' - -# wait for operator-controller to become available -kubectl rollout status -n olmv1-system deployment/operator-controller-controller-manager - -# create install namespace -kubectl create ns argocd-system - -# create installer service account -kubectl create serviceaccount -n argocd-system argocd-installer - -# give installer service account admin privileges (not for production environments) -kubectl create clusterrolebinding argocd-installer-crb --clusterrole=cluster-admin --serviceaccount=argocd-system:argocd-installer - -# install cluster extension in own namespace install mode (watch-namespace == install namespace == argocd-system) -cat ${DEMO_RESOURCE_DIR}/own-namespace-demo.yaml - -# apply cluster extension -kubectl apply -f ${DEMO_RESOURCE_DIR}/own-namespace-demo.yaml - -# wait for cluster extension installation to succeed -kubectl wait --for=condition=Installed clusterextension/argocd-operator --timeout="60s" - -# check argocd-operator controller deployment pod template olm.targetNamespaces annotation -kubectl get deployments -n argocd-system argocd-operator-controller-manager -o jsonpath="{.spec.template.metadata.annotations.olm\.targetNamespaces}" - -# check for argocd-operator rbac in watch namespace -kubectl get roles,rolebindings -n argocd-system -o name - -# get controller service-account name -kubectl get deployments -n argocd-system argocd-operator-controller-manager -o jsonpath="{.spec.template.spec.serviceAccount}" - -# check service account for role binding is the same as controller service-account -rolebinding=$(kubectl get rolebindings -n argocd-system -o name | grep 'argocd-operator' | head -n 1) -kubectl get -n argocd-system $rolebinding -o jsonpath='{.subjects}' | jq .[0] - -echo "Demo completed successfully!" - -# cleanup resources -echo "Cleaning up demo resources..." -kubectl delete clusterextension argocd-operator --ignore-not-found=true -kubectl delete namespace argocd-system --ignore-not-found=true -kubectl delete clusterrolebinding argocd-installer-crb --ignore-not-found=true - -# restore standard manifests and reset deployment (removes experimental feature gates) -echo "Restoring standard manifests..." -kubectl apply -f "$(dirname "${BASH_SOURCE[0]}")/../../manifests/standard.yaml" - -# wait for standard CRDs to be available -kubectl wait --for condition=established --timeout=60s crd/clusterextensions.olm.operatorframework.io - -# wait for operator-controller to become available with standard config -kubectl rollout status -n olmv1-system deployment/operator-controller-controller-manager - -echo "Demo cleanup completed!" diff --git a/hack/demo/resources/own-namespace-demo.yaml b/hack/demo/resources/own-namespace-demo.yaml deleted file mode 100644 index b22db7aa04..0000000000 --- a/hack/demo/resources/own-namespace-demo.yaml +++ /dev/null @@ -1,13 +0,0 @@ -apiVersion: olm.operatorframework.io/v1 -kind: ClusterExtension -metadata: - name: argocd-operator -spec: - namespace: argocd-system - serviceAccount: - name: argocd-installer - source: - sourceType: Catalog - catalog: - packageName: argocd-operator - version: 0.6.0 diff --git a/hack/demo/resources/single-namespace-demo.yaml b/hack/demo/resources/single-namespace-demo.yaml deleted file mode 100644 index 9c1ac17f9f..0000000000 --- a/hack/demo/resources/single-namespace-demo.yaml +++ /dev/null @@ -1,17 +0,0 @@ -apiVersion: olm.operatorframework.io/v1 -kind: ClusterExtension -metadata: - name: argocd-operator -spec: - namespace: argocd-system - serviceAccount: - name: argocd-installer - config: - configType: Inline - inline: - watchNamespace: argocd - source: - sourceType: Catalog - catalog: - packageName: argocd-operator - version: 0.6.0 diff --git a/hack/demo/resources/synthetic-user-perms/argocd-clusterextension.yaml b/hack/demo/resources/synthetic-user-perms/argocd-clusterextension.yaml deleted file mode 100644 index 7eb5a7082b..0000000000 --- a/hack/demo/resources/synthetic-user-perms/argocd-clusterextension.yaml +++ /dev/null @@ -1,13 +0,0 @@ -apiVersion: olm.operatorframework.io/v1 -kind: ClusterExtension -metadata: - name: argocd-operator -spec: - namespace: argocd-system - serviceAccount: - name: "olm.synthetic-user" - source: - sourceType: Catalog - catalog: - packageName: argocd-operator - version: 0.6.0 diff --git a/hack/demo/resources/synthetic-user-perms/cegroup-admin-binding.yaml b/hack/demo/resources/synthetic-user-perms/cegroup-admin-binding.yaml deleted file mode 100644 index d0ab570f7b..0000000000 --- a/hack/demo/resources/synthetic-user-perms/cegroup-admin-binding.yaml +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding -metadata: - name: clusterextensions-group-admin-binding -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: cluster-admin -subjects: - - kind: Group - name: "olm:clusterextensions" diff --git a/hack/demo/resources/webhook-provider-certmanager/mutating-webhook-test.yaml b/hack/demo/resources/webhook-provider-certmanager/mutating-webhook-test.yaml deleted file mode 100644 index 571940204a..0000000000 --- a/hack/demo/resources/webhook-provider-certmanager/mutating-webhook-test.yaml +++ /dev/null @@ -1,7 +0,0 @@ -apiVersion: webhook.operators.coreos.io/v1 -kind: webhooktest -metadata: - namespace: webhook-operator - name: mutating-webhook-test -spec: - valid: true diff --git a/hack/demo/resources/webhook-provider-certmanager/validating-webhook-test.yaml b/hack/demo/resources/webhook-provider-certmanager/validating-webhook-test.yaml deleted file mode 100644 index 227ab8417f..0000000000 --- a/hack/demo/resources/webhook-provider-certmanager/validating-webhook-test.yaml +++ /dev/null @@ -1,7 +0,0 @@ -apiVersion: webhook.operators.coreos.io/v1 -kind: webhooktest -metadata: - namespace: webhook-operator - name: validating-webhook-test -spec: - valid: false diff --git a/hack/demo/resources/webhook-provider-certmanager/webhook-operator-catalog.yaml b/hack/demo/resources/webhook-provider-certmanager/webhook-operator-catalog.yaml deleted file mode 100644 index ff325c0646..0000000000 --- a/hack/demo/resources/webhook-provider-certmanager/webhook-operator-catalog.yaml +++ /dev/null @@ -1,9 +0,0 @@ -apiVersion: olm.operatorframework.io/v1 -kind: ClusterCatalog -metadata: - name: webhook-operator-catalog -spec: - source: - type: Image - image: - ref: quay.io/operator-framework/webhook-operator-index:0.0.3 diff --git a/hack/demo/resources/webhook-provider-certmanager/webhook-operator-extension.yaml b/hack/demo/resources/webhook-provider-certmanager/webhook-operator-extension.yaml deleted file mode 100644 index 19b7eceb08..0000000000 --- a/hack/demo/resources/webhook-provider-certmanager/webhook-operator-extension.yaml +++ /dev/null @@ -1,15 +0,0 @@ -apiVersion: olm.operatorframework.io/v1 -kind: ClusterExtension -metadata: - name: webhook-operator -spec: - namespace: webhook-operator - serviceAccount: - name: webhook-operator-installer - source: - catalog: - packageName: webhook-operator - version: 0.0.1 - selector: {} - upgradeConstraintPolicy: CatalogProvided - sourceType: Catalog diff --git a/hack/demo/single-namespace-demo-script.sh b/hack/demo/single-namespace-demo-script.sh deleted file mode 100755 index 4daa66856b..0000000000 --- a/hack/demo/single-namespace-demo-script.sh +++ /dev/null @@ -1,73 +0,0 @@ -#!/usr/bin/env bash - -# -# Welcome to the SingleNamespace install mode demo -# -set -e -trap 'echo "Demo ran into error"; trap - SIGTERM && kill -- -$$; exit 1' ERR SIGINT SIGTERM EXIT - -# install experimental CRDs with config field support -kubectl apply -f "$(dirname "${BASH_SOURCE[0]}")/../../manifests/experimental.yaml" - -# wait for experimental CRDs to be available -kubectl wait --for condition=established --timeout=60s crd/clusterextensions.olm.operatorframework.io - -# enable 'SingleOwnNamespaceInstallSupport' feature gate -kubectl patch deployment -n olmv1-system operator-controller-controller-manager --type='json' -p='[{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value": "--feature-gates=SingleOwnNamespaceInstallSupport=true"}]' - -# wait for operator-controller to become available -kubectl rollout status -n olmv1-system deployment/operator-controller-controller-manager - -# create install namespace -kubectl create ns argocd-system - -# create installer service account -kubectl create serviceaccount -n argocd-system argocd-installer - -# give installer service account admin privileges (not for production environments) -kubectl create clusterrolebinding argocd-installer-crb --clusterrole=cluster-admin --serviceaccount=argocd-system:argocd-installer - -# create watch namespace -kubectl create namespace argocd - -# install cluster extension in single namespace install mode (watch namespace != install namespace) -cat ${DEMO_RESOURCE_DIR}/single-namespace-demo.yaml - -# apply cluster extension -kubectl apply -f ${DEMO_RESOURCE_DIR}/single-namespace-demo.yaml - -# wait for cluster extension installation to succeed -kubectl wait --for=condition=Installed clusterextension/argocd-operator --timeout="60s" - -# check argocd-operator controller deployment pod template olm.targetNamespaces annotation -kubectl get deployments -n argocd-system argocd-operator-controller-manager -o jsonpath="{.spec.template.metadata.annotations.olm\.targetNamespaces}" - -# check for argocd-operator rbac in watch namespace -kubectl get roles,rolebindings -n argocd -o name - -# get controller service-account name -kubectl get deployments -n argocd-system argocd-operator-controller-manager -o jsonpath="{.spec.template.spec.serviceAccount}" - -# check service account for role binding is the controller deployment service account -rolebinding=$(kubectl get rolebindings -n argocd -o name | grep 'argocd-operator' | head -n 1) -kubectl get -n argocd $rolebinding -o jsonpath='{.subjects}' | jq .[0] - -echo "Demo completed successfully!" - -# cleanup resources -echo "Cleaning up demo resources..." -kubectl delete clusterextension argocd-operator --ignore-not-found=true -kubectl delete namespace argocd-system argocd --ignore-not-found=true -kubectl delete clusterrolebinding argocd-installer-crb --ignore-not-found=true - -# restore standard manifests and reset deployment (removes experimental feature gates) -echo "Restoring standard manifests..." -kubectl apply -f "$(dirname "${BASH_SOURCE[0]}")/../../manifests/standard.yaml" - -# wait for standard CRDs to be available -kubectl wait --for condition=established --timeout=60s crd/clusterextensions.olm.operatorframework.io - -# wait for operator-controller to become available with standard config -kubectl rollout status -n olmv1-system deployment/operator-controller-controller-manager - -echo "Demo cleanup completed!" diff --git a/hack/demo/synthetic-user-cluster-admin-demo-script.sh b/hack/demo/synthetic-user-cluster-admin-demo-script.sh deleted file mode 100755 index 4790e46e77..0000000000 --- a/hack/demo/synthetic-user-cluster-admin-demo-script.sh +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env bash - -# -# Welcome to the SingleNamespace install mode demo -# -trap "trap - SIGTERM && kill -- -$$" SIGINT SIGTERM EXIT - -# enable 'SyntheticPermissions' feature -kubectl kustomize config/overlays/featuregate/synthetic-user-permissions | kubectl apply -f - - -# wait for operator-controller to become available -kubectl rollout status -n olmv1-system deployment/operator-controller-controller-manager - -# create install namespace -kubectl create ns argocd-system - -# give cluster extension group cluster admin privileges - all cluster extensions installer users will be cluster admin -bat --style=plain ${DEMO_RESOURCE_DIR}/synthetic-user-perms/cegroup-admin-binding.yaml - -# apply cluster role binding -kubectl apply -f ${DEMO_RESOURCE_DIR}/synthetic-user-perms/cegroup-admin-binding.yaml - -# install cluster extension - for now .spec.serviceAccount = "olm.synthetic-user" -bat --style=plain ${DEMO_RESOURCE_DIR}/synthetic-user-perms/argocd-clusterextension.yaml - -# apply cluster extension -kubectl apply -f ${DEMO_RESOURCE_DIR}/synthetic-user-perms/argocd-clusterextension.yaml - -# wait for cluster extension installation to succeed -kubectl wait --for=condition=Installed clusterextension/argocd-operator --timeout="60s" diff --git a/hack/demo/webhook-provider-certmanager-demo-script.sh b/hack/demo/webhook-provider-certmanager-demo-script.sh deleted file mode 100755 index ba723ca6a8..0000000000 --- a/hack/demo/webhook-provider-certmanager-demo-script.sh +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/env bash - -# -# Welcome to the webhook support with CertManager demo -# -trap "trap - SIGTERM && kill -- -$$" SIGINT SIGTERM EXIT - -# enable 'WebhookProviderCertManager' feature -kubectl kustomize config/overlays/featuregate/webhook-provider-certmanager | kubectl apply -f - - -# wait for operator-controller to become available -kubectl rollout status -n olmv1-system deployment/operator-controller-controller-manager - -# create webhook-operator catalog -cat ${DEMO_RESOURCE_DIR}/webhook-provider-certmanager/webhook-operator-catalog.yaml -kubectl apply -f ${DEMO_RESOURCE_DIR}/webhook-provider-certmanager/webhook-operator-catalog.yaml - -# wait for catalog to be serving -kubectl wait --for=condition=Serving clustercatalog/webhook-operator-catalog --timeout="60s" - -# create install namespace -kubectl create ns webhook-operator - -# create installer service account -kubectl create serviceaccount -n webhook-operator webhook-operator-installer - -# give installer service account admin privileges -kubectl create clusterrolebinding webhook-operator-installer-crb --clusterrole=cluster-admin --serviceaccount=webhook-operator:webhook-operator-installer - -# install webhook operator clusterextension -cat ${DEMO_RESOURCE_DIR}/webhook-provider-certmanager/webhook-operator-extension.yaml - -# apply cluster extension -kubectl apply -f ${DEMO_RESOURCE_DIR}/webhook-provider-certmanager/webhook-operator-extension.yaml - -# wait for cluster extension installation to succeed -kubectl wait --for=condition=Installed clusterextension/webhook-operator --timeout="60s" - -# wait for webhook-operator deployment to become available and back the webhook service -kubectl wait --for=condition=Available -n webhook-operator deployments/webhook-operator-webhook - -# demonstrate working validating webhook -cat ${DEMO_RESOURCE_DIR}/webhook-provider-certmanager/validating-webhook-test.yaml - -# resource creation should be rejected by the validating webhook due to bad attribute value -kubectl apply -f ${DEMO_RESOURCE_DIR}/webhook-provider-certmanager/validating-webhook-test.yaml - -# demonstrate working mutating webhook -cat ${DEMO_RESOURCE_DIR}/webhook-provider-certmanager/mutating-webhook-test.yaml - -# apply resource -kubectl apply -f ${DEMO_RESOURCE_DIR}/webhook-provider-certmanager/mutating-webhook-test.yaml - -# get webhooktest resource in v1 schema - resource should have new .spec.mutate attribute -kubectl get webhooktest.v1.webhook.operators.coreos.io -n webhook-operator mutating-webhook-test -o yaml - -# demonstrate working conversion webhook by getting webhook test resource in v2 schema - the .spec attributes should now be under the .spec.conversion stanza -kubectl get webhooktest.v2.webhook.operators.coreos.io -n webhook-operator mutating-webhook-test -o yaml - -# this concludes the webhook support demo - Thank you! diff --git a/mkdocs.yml b/mkdocs.yml index e891896222..6f58afa566 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -55,6 +55,10 @@ nav: - Contributing: contribute/contributing.md - Developing OLM v1: contribute/developer.md +plugins: + - search + - asciinema-player + markdown_extensions: - pymdownx.highlight: anchor_linenums: true diff --git a/requirements.txt b/requirements.txt index ac02303454..3b0401ac88 100644 --- a/requirements.txt +++ b/requirements.txt @@ -33,3 +33,4 @@ six==1.17.0 soupsieve==2.8.4 urllib3==2.7.0 watchdog==6.0.0 +mkdocs-asciinema-player==1.1.0 diff --git a/scripts/install.tpl.sh b/scripts/install.tpl.sh index 11a2f9a624..10ef8d4a54 100644 --- a/scripts/install.tpl.sh +++ b/scripts/install.tpl.sh @@ -45,6 +45,7 @@ fi default_catalogs_manifest=$DEFAULT_CATALOG cert_mgr_version=$CERT_MGR_VERSION install_default_catalogs=$INSTALL_DEFAULT_CATALOGS +catalog_wait_timeout=${CATALOG_WAIT_TIMEOUT:-60s} if [[ -z "$cert_mgr_version" ]]; then echo "Error: Missing CERT_MGR_VERSION variable" @@ -124,5 +125,5 @@ kubectl_wait "${olmv1_namespace}" "deployment/operator-controller-controller-man if [[ "${install_default_catalogs}" != "false" ]]; then kubectl apply -f "${default_catalogs_manifest}" - kubectl wait --for=condition=Serving "clustercatalog/operatorhubio" --timeout="60s" + kubectl wait --for=condition=Serving "clustercatalog/operatorhubio" --timeout="${catalog_wait_timeout}" fi diff --git a/test/e2e/features/demos.feature b/test/e2e/features/demos.feature new file mode 100644 index 0000000000..2973124403 --- /dev/null +++ b/test/e2e/features/demos.feature @@ -0,0 +1,113 @@ +@demo +Feature: OLM v1 Demos + + Background: + Given OLM is available + And catalog "operatorhubio" reports Serving as True with Reason Available + + Scenario: ClusterCatalog Quickstart + When catalog "operatorhubio" reports Progressing as True + Then catalog "operatorhubio" contains some packages + And package "wavefront" in catalog "operatorhubio" has some channels defined + And package "wavefront" in catalog "operatorhubio" has some bundles published + + @SingleOwnNamespaceInstallSupport + Scenario: SingleNamespace Install Mode + Given ServiceAccount "mariadb-installer" in test namespace is cluster admin + And namespace is applied + """ + apiVersion: v1 + kind: Namespace + metadata: + name: mariadb-watch + """ + When ClusterExtension is applied + """ + apiVersion: olm.operatorframework.io/v1 + kind: ClusterExtension + metadata: + name: ${NAME} + spec: + namespace: ${TEST_NAMESPACE} + serviceAccount: + name: mariadb-installer + config: + configType: Inline + inline: + watchNamespace: mariadb-watch + source: + sourceType: Catalog + catalog: + packageName: mariadb-operator + """ + Then ClusterExtension is rolled out + And ClusterExtension is available + And operator "mariadb-operator-helm-controller-manager" target namespace is "mariadb-watch" + And rolebindings in namespace "mariadb-watch" reference service account "mariadb-operator-helm-controller-manager" in namespace "${TEST_NAMESPACE}" + + @SingleOwnNamespaceInstallSupport + Scenario: OwnNamespace Install Mode + Given ServiceAccount "mariadb-installer" in test namespace is cluster admin + When ClusterExtension is applied + """ + apiVersion: olm.operatorframework.io/v1 + kind: ClusterExtension + metadata: + name: ${NAME} + spec: + namespace: ${TEST_NAMESPACE} + serviceAccount: + name: mariadb-installer + config: + configType: Inline + inline: + watchNamespace: ${TEST_NAMESPACE} + source: + sourceType: Catalog + catalog: + packageName: mariadb-operator + """ + Then ClusterExtension is rolled out + And ClusterExtension is available + And operator "mariadb-operator-helm-controller-manager" target namespace is "${TEST_NAMESPACE}" + + @WebhookProviderCertManager + Scenario: Webhook Support + Given ServiceAccount "sa-installer" in test namespace is cluster admin + When ClusterExtension is applied + """ + apiVersion: olm.operatorframework.io/v1 + kind: ClusterExtension + metadata: + name: ${NAME} + spec: + namespace: ${TEST_NAMESPACE} + serviceAccount: + name: sa-installer + source: + sourceType: Catalog + catalog: + packageName: telegraf-operator + """ + Then ClusterExtension is rolled out + And ClusterExtension is available + When resource is applied + """ + apiVersion: v1 + kind: Pod + metadata: + name: test-pod + namespace: ${TEST_NAMESPACE} + annotations: + telegraf.influxdata.com/class: default + telegraf.influxdata.com/inputs: | + [[inputs.cpu]] + percpu = false + totalcpu = true + spec: + containers: + - name: app + image: busybox + command: ["sleep", "3600"] + """ + Then pod "test-pod" in test namespace has 2 containers diff --git a/test/e2e/features_test.go b/test/e2e/features_test.go index 05e91b4cf4..65eaebe255 100644 --- a/test/e2e/features_test.go +++ b/test/e2e/features_test.go @@ -139,5 +139,6 @@ func InitializeSuite(tc *godog.TestSuiteContext) { func InitializeScenario(sc *godog.ScenarioContext) { steps.RegisterSteps(sc) + steps.RegisterDemoSteps(sc) steps.RegisterHooks(sc) } diff --git a/test/e2e/steps/asciicast_hooks.go b/test/e2e/steps/asciicast_hooks.go new file mode 100644 index 0000000000..9a2333ee13 --- /dev/null +++ b/test/e2e/steps/asciicast_hooks.go @@ -0,0 +1,82 @@ +package steps + +import ( + "context" + "os" + "strings" + + "github.com/cucumber/godog" +) + +const demoTag = "@demo" + +var demoOutputDir = "docs/demos" + +func init() { + if dir := os.Getenv("DEMO_OUTPUT_DIR"); dir != "" { + demoOutputDir = dir + } +} + +func RegisterAsciiCastHooks(sc *godog.ScenarioContext) { + sc.Before(startRecordingIfDemo) + sc.StepContext().Before(beforeStep) + sc.StepContext().After(afterStep) + sc.After(stopRecordingIfDemo) +} + +func hasTag(sc *godog.Scenario, tag string) bool { + for _, t := range sc.Tags { + if t.Name == tag { + return true + } + } + return false +} + +func startRecordingIfDemo(ctx context.Context, sc *godog.Scenario) (context.Context, error) { + if !hasTag(sc, demoTag) { + return ctx, nil + } + rec := NewAsciiCastRecorder(sc.Name, demoOutputDir) + logger.Info("Starting asciicast recording", "scenario", sc.Name, "output", rec.castPath) + return WithRecorder(ctx, rec), nil +} + +func beforeStep(ctx context.Context, st *godog.Step) (context.Context, error) { + if rec := RecorderFromContext(ctx); rec != nil { + rec.BeginStep(st.Text) + } + return ctx, nil +} + +func afterStep(ctx context.Context, st *godog.Step, status godog.StepResultStatus, err error) (context.Context, error) { + rec := RecorderFromContext(ctx) + if rec == nil { + return ctx, nil + } + if status == godog.StepPassed { + rec.CommitStep() + } else { + rec.DiscardStep() + } + return ctx, nil +} + +func stopRecordingIfDemo(ctx context.Context, sc *godog.Scenario, err error) (context.Context, error) { + rec := RecorderFromContext(ctx) + if rec == nil { + return ctx, nil + } + if stopErr := rec.Stop(); stopErr != nil { + logger.Info("Failed to write asciicast file", "error", stopErr) + if err == nil { + return ctx, stopErr + } + } else { + // Slugify scenario name the same way as the recorder + slug := strings.ToLower(strings.ReplaceAll(sc.Name, " ", "-")) + logger.Info("Asciicast recording saved", "file", demoOutputDir+"/"+slug+".cast") + } + return ctx, nil +} diff --git a/test/e2e/steps/asciicast_recorder.go b/test/e2e/steps/asciicast_recorder.go new file mode 100644 index 0000000000..62ef69e01b --- /dev/null +++ b/test/e2e/steps/asciicast_recorder.go @@ -0,0 +1,220 @@ +package steps + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +type recorderContextKey struct{} + +func WithRecorder(ctx context.Context, rec *AsciiCastRecorder) context.Context { + return context.WithValue(ctx, recorderContextKey{}, rec) +} + +func RecorderFromContext(ctx context.Context) *AsciiCastRecorder { + rec, _ := ctx.Value(recorderContextKey{}).(*AsciiCastRecorder) + return rec +} + +type asciicastHeader struct { + Version int `json:"version"` + Width int `json:"width"` + Height int `json:"height"` + Title string `json:"title,omitempty"` + Env map[string]string `json:"env,omitempty"` +} + +type recordedEntry struct { + command string + stdin string + stdout string + stderr string + timestamp time.Time + duration time.Duration +} + +// AsciiCastRecorder generates asciicast v2 recordings from demo scenario execution. +// Format spec: https://docs.asciinema.org/manual/asciicast/v2/ +type AsciiCastRecorder struct { + entries []recordedEntry + stepBuffer []recordedEntry + stepIndex map[string]int + stepText string + startTime time.Time + castPath string +} + +func NewAsciiCastRecorder(scenarioName, outputDir string) *AsciiCastRecorder { + slug := strings.ToLower(strings.ReplaceAll(scenarioName, " ", "-")) + return &AsciiCastRecorder{ + castPath: filepath.Join(outputDir, slug+".cast"), + startTime: time.Now(), + } +} + +func (r *AsciiCastRecorder) BeginStep(stepText string) { + r.stepBuffer = nil + r.stepIndex = make(map[string]int) + r.stepText = stepText +} + +func (r *AsciiCastRecorder) CommitStep() { + if len(r.stepBuffer) == 0 { + return + } + comment := recordedEntry{ + command: "", + stdout: "\033[34m# " + r.stepText + "\033[0m", // ANSI blue for step comments + timestamp: r.stepBuffer[0].timestamp, + } + r.entries = append(r.entries, comment) + r.entries = append(r.entries, r.stepBuffer...) + r.stepBuffer = nil + r.stepIndex = nil +} + +func (r *AsciiCastRecorder) DiscardStep() { + r.stepBuffer = nil + r.stepIndex = nil +} + +func (r *AsciiCastRecorder) RecordCommand(command, stdout, stderr string, duration time.Duration) { + if r.stepIndex == nil { + return + } + if idx, ok := r.stepIndex[command]; ok { + r.stepBuffer[idx].stdout = stdout + r.stepBuffer[idx].stderr = stderr + r.stepBuffer[idx].duration = duration + return + } + r.stepIndex[command] = len(r.stepBuffer) + r.stepBuffer = append(r.stepBuffer, recordedEntry{ + command: command, + stdout: stdout, + stderr: stderr, + timestamp: time.Now(), + duration: duration, + }) +} + +func (r *AsciiCastRecorder) RecordCommandWithInput(command, stdin, stdout, stderr string, duration time.Duration) { + if r.stepIndex == nil { + return + } + key := command + "\x00" + stdin + if idx, ok := r.stepIndex[key]; ok { + r.stepBuffer[idx].stdout = stdout + r.stepBuffer[idx].stderr = stderr + r.stepBuffer[idx].duration = duration + return + } + r.stepIndex[key] = len(r.stepBuffer) + r.stepBuffer = append(r.stepBuffer, recordedEntry{ + command: command, + stdin: stdin, + stdout: stdout, + stderr: stderr, + timestamp: time.Now(), + duration: duration, + }) +} + +func (r *AsciiCastRecorder) RecordCustom(displayCommand, stdout, stderr string) { + r.stepBuffer = r.stepBuffer[:0] + r.stepIndex = make(map[string]int) + r.stepBuffer = append(r.stepBuffer, recordedEntry{ + command: displayCommand, + stdout: stdout, + stderr: stderr, + timestamp: time.Now(), + }) +} + +func (r *AsciiCastRecorder) Stop() error { + if err := os.MkdirAll(filepath.Dir(r.castPath), 0o755); err != nil { + return fmt.Errorf("failed to create output directory: %w", err) + } + f, err := os.Create(r.castPath) + if err != nil { + return fmt.Errorf("failed to create cast file %s: %w", r.castPath, err) + } + defer f.Close() + + header := asciicastHeader{ + Version: 2, + Width: 120, + Height: 40, + Env: map[string]string{"TERM": "xterm-256color", "SHELL": "/bin/bash"}, + } + headerBytes, err := json.Marshal(header) + if err != nil { + return fmt.Errorf("failed to marshal header: %w", err) + } + if _, err := fmt.Fprintf(f, "%s\n", headerBytes); err != nil { + return err + } + + for _, entry := range r.entries { + elapsed := entry.timestamp.Sub(r.startTime).Seconds() + + if entry.command == "" { + if entry.stdout != "" { + if err := writeEvent(f, elapsed, "\r\n"+toTerminalLines(entry.stdout)); err != nil { + return err + } + } + continue + } + + cmdText := "\033[1;32m$ " + entry.command + "\033[0m\r\n" // ANSI bold green for prompt and command + if err := writeEvent(f, elapsed, cmdText); err != nil { + return err + } + + if entry.stdin != "" { + heredoc := "<= 2 { featureGates[catalogdHAFeature] = true } @@ -243,6 +246,10 @@ func ScenarioCleanup(ctx context.Context, _ *godog.Scenario, err error) (context if sc.proxy != nil { sc.proxy.stop() } + // Stop catalog port-forward if one was started. + if sc.catalogCleanup != nil { + sc.catalogCleanup() + } // Restore any deployments that were modified during the scenario. Runs // unconditionally (even on failure) to prevent a misconfigured deployment @@ -253,7 +260,7 @@ func ScenarioCleanup(ctx context.Context, _ *godog.Scenario, err error) (context if dr.patchedArgs { if err2 := patchDeploymentArgs(dr.namespace, dr.name, dr.originalArgs); err2 != nil { logger.Info("Error restoring deployment args", "name", dr.name, "error", err2) - } else if _, err2 := k8sClient("rollout", "status", "-n", dr.namespace, + } else if _, err2 := k8sClient(ctx, "rollout", "status", "-n", dr.namespace, fmt.Sprintf("deployment/%s", dr.name), "--timeout=2m"); err2 != nil { logger.Info("Timeout waiting for deployment rollout after restore", "name", dr.name) } @@ -284,7 +291,7 @@ func ScenarioCleanup(ctx context.Context, _ *godog.Scenario, err error) (context if r.namespace != "" { args = append(args, "-n", r.namespace) } - if _, err := k8sClient(args...); err != nil { + if _, err := k8sClient(ctx, args...); err != nil { logger.Info("Error deleting resource", "name", r.name, "namespace", r.namespace, "stderr", stderrOutput(err)) } return nil diff --git a/test/e2e/steps/proxy_steps.go b/test/e2e/steps/proxy_steps.go index daa13f30e1..02ac969114 100644 --- a/test/e2e/steps/proxy_steps.go +++ b/test/e2e/steps/proxy_steps.go @@ -157,7 +157,7 @@ func kindGatewayIP() (string, error) { // the "default" namespace, which is the address client-go uses to reach the // API server from inside a pod (via the KUBERNETES_SERVICE_HOST env var). func kubernetesClusterIP() (string, error) { - ip, err := k8sClient("get", "service", "kubernetes", "-n", "default", + ip, err := k8sClient(context.Background(), "get", "service", "kubernetes", "-n", "default", "-o", "jsonpath={.spec.clusterIP}") if err != nil { return "", fmt.Errorf("failed to get kubernetes service cluster IP: %w", err) @@ -168,7 +168,7 @@ func kubernetesClusterIP() (string, error) { // getDeploymentContainerEnv returns the environment variables for the named // container in the given deployment, as a slice of "NAME=VALUE" strings. func getDeploymentContainerEnv(deploymentName, namespace, containerName string) ([]string, error) { - raw, err := k8sClient("get", "deployment", deploymentName, "-n", namespace, "-o", "json") + raw, err := k8sClient(context.Background(), "get", "deployment", deploymentName, "-n", namespace, "-o", "json") if err != nil { return nil, fmt.Errorf("failed to get deployment %s/%s: %w", namespace, deploymentName, err) } @@ -196,7 +196,7 @@ func getDeploymentContainerEnv(deploymentName, namespace, containerName string) // the JSON Patch "add" operation, which creates the env field if absent. func setDeploymentEnvVars(deploymentName, namespace, containerName string, env []string) error { // Fetch the deployment to find the container index. - raw, err := k8sClient("get", "deployment", deploymentName, "-n", namespace, "-o", "json") + raw, err := k8sClient(context.Background(), "get", "deployment", deploymentName, "-n", namespace, "-o", "json") if err != nil { return fmt.Errorf("failed to get deployment %s/%s: %w", namespace, deploymentName, err) } @@ -243,12 +243,12 @@ func setDeploymentEnvVars(deploymentName, namespace, containerName string, env [ return fmt.Errorf("failed to marshal patch: %w", err) } - if _, err := k8sClient("patch", "deployment", deploymentName, "-n", namespace, + if _, err := k8sClient(context.Background(), "patch", "deployment", deploymentName, "-n", namespace, "--type=json", fmt.Sprintf("--patch=%s", string(patchBytes))); err != nil { return fmt.Errorf("failed to patch deployment %s/%s: %w", namespace, deploymentName, err) } - if _, err := k8sClient("rollout", "status", "deployment", deploymentName, "-n", namespace, + if _, err := k8sClient(context.Background(), "rollout", "status", "deployment", deploymentName, "-n", namespace, "--timeout=5m"); err != nil { return fmt.Errorf("rollout of deployment %s/%s did not complete: %w", namespace, deploymentName, err) } diff --git a/test/e2e/steps/steps.go b/test/e2e/steps/steps.go index 52aed3a5e2..e1686ea526 100644 --- a/test/e2e/steps/steps.go +++ b/test/e2e/steps/steps.go @@ -6,6 +6,7 @@ import ( "context" "crypto/tls" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -74,7 +75,7 @@ var ( k8sCli string deployImageRegistry = sync.OnceValue(func() error { // Only deploy the registry on kind clusters - providerID, err := k8sClient("get", "nodes", "-o", "jsonpath={.items[0].spec.providerID}") + providerID, err := k8sClient(context.Background(), "get", "nodes", "-o", "jsonpath={.items[0].spec.providerID}") if err != nil || !strings.HasPrefix(providerID, "kind://") { return nil } @@ -148,6 +149,7 @@ func RegisterSteps(sc *godog.ScenarioContext) { sc.Step(`^(?i)resource "([^"]+)" is (?:eventually not found|not installed)$`, ResourceEventuallyNotFound) sc.Step(`^(?i)resource "([^"]+)" exists$`, ResourceAvailable) sc.Step(`^(?i)resource is applied$`, ResourceIsApplied) + sc.Step(`^(?i)namespace is applied$`, ResourceIsApplied) sc.Step(`^(?i)deployment "([^"]+)" reports as (not ready|ready)$`, MarkDeploymentReadiness) sc.Step(`^(?i)resource apply fails with error msg containing "([^"]+)"$`, ResourceApplyFails) @@ -240,25 +242,66 @@ func namespaceForComponent(component string) string { return olmNamespace } -func k8sClient(args ...string) (string, error) { - cmd := exec.Command(k8sCli, args...) +func k8sClient(ctx context.Context, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, k8sCli, args...) logger.V(1).Info("Running", "command", strings.Join(cmd.Args, " ")) cmd.Env = append(os.Environ(), fmt.Sprintf("KUBECONFIG=%s", kubeconfigPath)) - b, err := cmd.Output() + + var stdoutBuf, stderrBuf bytes.Buffer + cmd.Stdout = &stdoutBuf + cmd.Stderr = &stderrBuf + + start := time.Now() + err := cmd.Run() + elapsed := time.Since(start) + + output := stdoutBuf.String() + stderrStr := stderrBuf.String() + if err != nil { - logger.V(1).Info("Failed to run", "command", strings.Join(cmd.Args, " "), "stderr", stderrOutput(err), "error", err) + logger.V(1).Info("Failed to run", "command", strings.Join(cmd.Args, " "), "stderr", stderrStr, "error", err) + // Inject stderr into ExitError so stderrOutput() can extract it. + // cmd.Run() with explicit cmd.Stderr does not populate ExitError.Stderr. + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + exitErr.Stderr = []byte(stderrStr) + } } - output := string(b) logger.V(1).Info("Output", "command", strings.Join(cmd.Args, " "), "output", output) + + if rec := RecorderFromContext(ctx); rec != nil { + rec.RecordCommand(strings.Join(cmd.Args, " "), output, stderrStr, elapsed) + } return output, err } -func k8scliWithInput(yaml string, args ...string) (string, error) { - cmd := exec.Command(k8sCli, args...) +func k8scliWithInput(ctx context.Context, yaml string, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, k8sCli, args...) cmd.Stdin = bytes.NewBufferString(yaml) cmd.Env = append(os.Environ(), fmt.Sprintf("KUBECONFIG=%s", kubeconfigPath)) - b, err := cmd.Output() - return string(b), err + + var stdoutBuf, stderrBuf bytes.Buffer + cmd.Stdout = &stdoutBuf + cmd.Stderr = &stderrBuf + + start := time.Now() + err := cmd.Run() + elapsed := time.Since(start) + + output := stdoutBuf.String() + stderrStr := stderrBuf.String() + + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + exitErr.Stderr = []byte(stderrStr) + } + } + + if rec := RecorderFromContext(ctx); rec != nil { + rec.RecordCommandWithInput(strings.Join(cmd.Args, " "), yaml, output, stderrStr, elapsed) + } + return output, err } // projectRootDir finds the project root by walking up from the source file until go.mod is found. @@ -301,12 +344,17 @@ func ImageRegistryIsAvailable() error { // OLMisAvailable waits for the OLM operator-controller deployment to become available. Polls with timeout. func OLMisAvailable(ctx context.Context) error { require.Eventually(godog.T(ctx), func() bool { - v, err := k8sClient("get", "deployment", "-n", olmNamespace, olmDeploymentName, "-o", "jsonpath='{.status.conditions[?(@.type==\"Available\")].status}'") + v, err := k8sClient(ctx, "get", "deployment", "-n", olmNamespace, olmDeploymentName, "-o", "jsonpath='{.status.conditions[?(@.type==\"Available\")].status}'") if err != nil { return false } return v == "'True'" }, timeout, tick) + + if rec := RecorderFromContext(ctx); rec != nil { + out, _ := k8sClient(ctx, "get", "deployments", "-n", olmNamespace, "-l", "app.kubernetes.io/part-of=olm") + rec.RecordCustom(fmt.Sprintf("kubectl get deployments -n %s", olmNamespace), out, "") + } return nil } @@ -315,7 +363,7 @@ func BundleInstalled(ctx context.Context, name, version string) error { sc := scenarioCtx(ctx) name = substituteScenarioVars(name, sc) waitFor(ctx, func() bool { - v, err := k8sClient("get", "clusterextension", sc.clusterExtensionName, "-o", "jsonpath={.status.install.bundle}") + v, err := k8sClient(ctx, "get", "clusterextension", sc.clusterExtensionName, "-o", "jsonpath={.status.install.bundle}") if err != nil { return false } @@ -372,7 +420,7 @@ func ResourceApplyFails(ctx context.Context, errMsg string, yamlTemplate *godog. return fmt.Errorf("failed to parse resource yaml: %v", err) } waitFor(ctx, func() bool { - _, err := k8scliWithInput(yamlContent, "apply", "-f", "-") + _, err := k8scliWithInput(ctx, yamlContent, "apply", "-f", "-") if err == nil { return false } @@ -390,7 +438,7 @@ func ClusterExtensionOwnsClusterObjectSets(ctx context.Context, extName string, sc := scenarioCtx(ctx) extName = substituteScenarioVars(extName, sc) waitFor(ctx, func() bool { - out, err := k8sClient("get", "clusterobjectsets", + out, err := k8sClient(ctx, "get", "clusterobjectsets", "-l", fmt.Sprintf("olm.operatorframework.io/owner-name=%s", extName), "-o", "jsonpath={.items[*].metadata.name}") if err != nil { @@ -433,7 +481,7 @@ func ClusterExtensionVersionUpdate(ctx context.Context, version string) error { if err != nil { return err } - _, err = k8sClient("patch", "clusterextension", sc.clusterExtensionName, "--type", "merge", "-p", string(pb)) + _, err = k8sClient(ctx, "patch", "clusterextension", sc.clusterExtensionName, "--type", "merge", "-p", string(pb)) return err } @@ -450,7 +498,7 @@ func ClusterObjectSetLifecycleUpdate(ctx context.Context, cosName, lifecycle str if err != nil { return err } - _, err = k8sClient("patch", "clusterobjectset", cosName, "--type", "merge", "-p", string(pb)) + _, err = k8sClient(ctx, "patch", "clusterobjectset", cosName, "--type", "merge", "-p", string(pb)) return err } @@ -468,7 +516,7 @@ func ResourceIsApplied(ctx context.Context, yamlTemplate *godog.DocString) error if err != nil { return fmt.Errorf("failed to marshal resource yaml: %w", err) } - out, err := k8scliWithInput(string(annotatedYAML), "apply", "-f", "-") + out, err := k8scliWithInput(ctx, string(annotatedYAML), "apply", "-f", "-") if err != nil { return fmt.Errorf("failed to apply resource %v; err: %w; stderr: %s", out, err, stderrOutput(err)) } @@ -494,12 +542,24 @@ func ResourceIsApplied(ctx context.Context, yamlTemplate *godog.DocString) error func ClusterExtensionIsAvailable(ctx context.Context) error { sc := scenarioCtx(ctx) require.Eventually(godog.T(ctx), func() bool { - v, err := k8sClient("get", "clusterextension", sc.clusterExtensionName, "-o", "jsonpath={.status.conditions[?(@.type==\"Installed\")].status}") + v, err := k8sClient(ctx, "get", "clusterextension", sc.clusterExtensionName, "-o", "jsonpath={.status.conditions[?(@.type==\"Installed\")].status}") if err != nil { return false } return v == "True" }, timeout, tick) + + if rec := RecorderFromContext(ctx); rec != nil { + out, _ := k8sClient(ctx, "get", "clusterextension", sc.clusterExtensionName, "-o", "jsonpath={.status}") + var pretty bytes.Buffer + if err := json.Indent(&pretty, []byte(out), "", " "); err == nil { + out = pretty.String() + } + rec.RecordCustom( + fmt.Sprintf("kubectl get clusterextension %s -o jsonpath='{.status}' | jq .", sc.clusterExtensionName), + out+"\n", "", + ) + } return nil } @@ -508,7 +568,7 @@ func ClusterExtensionReconciledLatestGeneration(ctx context.Context) error { sc := scenarioCtx(ctx) waitFor(ctx, func() bool { // Get both generation and observedGeneration in a single kubectl call - output, err := k8sClient("get", "clusterextension", sc.clusterExtensionName, + output, err := k8sClient(ctx, "get", "clusterextension", sc.clusterExtensionName, "-o", "jsonpath={.metadata.generation},{.status.conditions[?(@.type=='Progressing')].observedGeneration}") if err != nil || output == "" { return false @@ -528,7 +588,7 @@ func ClusterExtensionReconciledLatestGeneration(ctx context.Context) error { func ClusterExtensionIsRolledOut(ctx context.Context) error { sc := scenarioCtx(ctx) require.Eventually(godog.T(ctx), func() bool { - v, err := k8sClient("get", "clusterextension", sc.clusterExtensionName, "-o", "jsonpath={.status.conditions[?(@.type==\"Progressing\")]}") + v, err := k8sClient(ctx, "get", "clusterextension", sc.clusterExtensionName, "-o", "jsonpath={.status.conditions[?(@.type==\"Progressing\")]}") if err != nil { return false } @@ -541,6 +601,11 @@ func ClusterExtensionIsRolledOut(ctx context.Context) error { return condition["status"] == "True" && condition["reason"] == "Succeeded" && condition["type"] == "Progressing" }, timeout, tick) + if rec := RecorderFromContext(ctx); rec != nil { + out, _ := k8sClient(ctx, "get", "clusterextension", sc.clusterExtensionName) + rec.RecordCustom(fmt.Sprintf("kubectl get clusterextension %s", sc.clusterExtensionName), out, "") + } + // Save ClusterExtension resources to test context for posterior checks if err := sc.GatherClusterExtensionObjects(); err != nil { return err @@ -642,7 +707,7 @@ func messageFragmentComparison(ctx context.Context, msgFragment *godog.DocString func waitForCondition(ctx context.Context, resourceType, resourceName, conditionType, conditionStatus string, conditionReason *string, msgCmp msgMatchFn) error { require.Eventually(godog.T(ctx), func() bool { - v, err := k8sClient("get", resourceType, resourceName, "-o", fmt.Sprintf("jsonpath={.status.conditions[?(@.type==\"%s\")]}", conditionType)) + v, err := k8sClient(ctx, "get", resourceType, resourceName, "-o", fmt.Sprintf("jsonpath={.status.conditions[?(@.type==\"%s\")]}", conditionType)) if err != nil { return false } @@ -702,7 +767,7 @@ func ClusterExtensionReportsConditionTransitionTime(ctx context.Context, conditi t := godog.T(ctx) // Get the ClusterExtension's creation timestamp and condition's lastTransitionTime - v, err := k8sClient("get", "clusterextension", sc.clusterExtensionName, "-o", + v, err := k8sClient(ctx, "get", "clusterextension", sc.clusterExtensionName, "-o", fmt.Sprintf("jsonpath={.metadata.creationTimestamp},{.status.conditions[?(@.type==\"%s\")].lastTransitionTime}", conditionType)) require.NoError(t, err) @@ -737,7 +802,7 @@ func ClusterExtensionReportsActiveRevisions(ctx context.Context, rawRevisionName } waitFor(ctx, func() bool { - v, err := k8sClient("get", "clusterextension", sc.clusterExtensionName, "-o", "jsonpath={.status.activeRevisions}") + v, err := k8sClient(ctx, "get", "clusterextension", sc.clusterExtensionName, "-o", "jsonpath={.status.activeRevisions}") if err != nil { return false } @@ -777,7 +842,7 @@ func ClusterObjectSetReportsConditionWithMessageFragment(ctx context.Context, re func TriggerClusterObjectSetReconciliation(ctx context.Context, cosName string) error { sc := scenarioCtx(ctx) cosName = substituteScenarioVars(cosName, sc) - _, err := k8sClient("annotate", "clusterobjectset", cosName, "--overwrite", + _, err := k8sClient(ctx, "annotate", "clusterobjectset", cosName, "--overwrite", fmt.Sprintf("e2e-trigger=%d", time.Now().UnixNano())) return err } @@ -790,7 +855,7 @@ func ClusterObjectSetHasObservedPhase(ctx context.Context, cosName, phaseName st phaseName = substituteScenarioVars(phaseName, sc) waitFor(ctx, func() bool { - out, err := k8sClient("get", "clusterobjectset", cosName, "-o", + out, err := k8sClient(ctx, "get", "clusterobjectset", cosName, "-o", fmt.Sprintf(`jsonpath={.status.observedPhases[?(@.name=="%s")].digest}`, phaseName)) if err != nil { return false @@ -859,7 +924,7 @@ func ClusterObjectSetObjectsNotFoundOrNotOwned(ctx context.Context, revisionName // Get the ClusterObjectSet to extract its phase objects var rev ocv1.ClusterObjectSet waitFor(ctx, func() bool { - out, err := k8sClient("get", "clusterobjectset", revisionName, "-o", "json") + out, err := k8sClient(ctx, "get", "clusterobjectset", revisionName, "-o", "json") if err != nil { return false } @@ -899,7 +964,7 @@ func ClusterObjectSetObjectsNotFoundOrNotOwned(ctx context.Context, revisionName if namespace != "" { args = append(args, "-n", namespace) } - out, err := k8sClient(args...) + out, err := k8sClient(ctx, args...) if err != nil { return false } @@ -1141,8 +1206,8 @@ func collectReferredSecretNames(ctx context.Context, revisionName string) ([]str // listReferredSecrets lists all Secrets in the OLM namespace that have the revision-name label // matching the given revision name. -func listReferredSecrets(_ context.Context, revisionName string) ([]corev1.Secret, error) { - out, err := k8sClient("get", "secrets", "-n", olmNamespace, +func listReferredSecrets(ctx context.Context, revisionName string) ([]corev1.Secret, error) { + out, err := k8sClient(ctx, "get", "secrets", "-n", olmNamespace, "-l", "olm.operatorframework.io/revision-name="+revisionName, "-o", "json") if err != nil { return nil, fmt.Errorf("listing referred secrets for revision %q: %w", revisionName, err) @@ -1163,7 +1228,7 @@ func ResourceAvailable(ctx context.Context, resource string) error { return fmt.Errorf("resource %s is not in the format /", resource) } waitFor(ctx, func() bool { - _, err := k8sClient("get", kind, name, "-n", sc.namespace) + _, err := k8sClient(ctx, "get", kind, name, "-n", sc.namespace) return err == nil }) return nil @@ -1177,7 +1242,7 @@ func ResourceRemoved(ctx context.Context, resource string) error { if !found { return fmt.Errorf("resource %s is not in the format /", resource) } - yaml, err := k8sClient("get", kind, name, "-n", sc.namespace, "-o", "yaml") + yaml, err := k8sClient(ctx, "get", kind, name, "-n", sc.namespace, "-o", "yaml") if err != nil { return err } @@ -1186,7 +1251,7 @@ func ResourceRemoved(ctx context.Context, resource string) error { return err } sc.removedResources = append(sc.removedResources, *obj) - _, err = k8sClient("delete", kind, name, "-n", sc.namespace) + _, err = k8sClient(ctx, "delete", kind, name, "-n", sc.namespace) return err } @@ -1200,7 +1265,7 @@ func ResourceEventuallyNotFound(ctx context.Context, resource string) error { } waitFor(ctx, func() bool { - obj, err := k8sClient("get", kind, name, "-n", sc.namespace, "--ignore-not-found", "-o", "yaml") + obj, err := k8sClient(ctx, "get", kind, name, "-n", sc.namespace, "--ignore-not-found", "-o", "yaml") return err == nil && strings.TrimSpace(obj) == "" }) return nil @@ -1219,7 +1284,7 @@ func ResourceMatches(ctx context.Context, resource string, requiredContentTempla return fmt.Errorf("failed to parse required resource yaml: %v", err) } waitFor(ctx, func() bool { - objJson, err := k8sClient("get", kind, name, "-n", sc.namespace, "-o", "json") + objJson, err := k8sClient(ctx, "get", kind, name, "-n", sc.namespace, "-o", "json") if err != nil { return false } @@ -1254,7 +1319,7 @@ func ResourceRestored(ctx context.Context, resource string) error { return fmt.Errorf("resource %s is not in the format /", resource) } waitFor(ctx, func() bool { - yaml, err := k8sClient("get", kind, name, "-n", sc.namespace, "-o", "yaml") + yaml, err := k8sClient(ctx, "get", kind, name, "-n", sc.namespace, "-o", "yaml") if err != nil { return false } @@ -1301,7 +1366,7 @@ func applyServiceAccount(ctx context.Context, serviceAccount string, keyValue .. } // Apply the ServiceAccount configuration - _, err = k8scliWithInput(yaml, "apply", "-f", "-") + _, err = k8scliWithInput(ctx, yaml, "apply", "-f", "-") if err != nil { return fmt.Errorf("failed to apply ServiceAccount configuration: %v: %s", err, stderrOutput(err)) } @@ -1329,7 +1394,7 @@ func applyPermissionsToServiceAccount(ctx context.Context, serviceAccount, rbacT } // Apply the RBAC configuration - _, err = k8scliWithInput(rbacYaml, "apply", "-f", "-") + _, err = k8scliWithInput(ctx, rbacYaml, "apply", "-f", "-") if err != nil { return fmt.Errorf("failed to apply RBAC configuration: %v: %s", err, stderrOutput(err)) } @@ -1443,7 +1508,7 @@ func SendMetricsRequest(ctx context.Context, serviceAccount string, endpoint str for k, v := range svc.Spec.Selector { podNameCmd = append(podNameCmd, fmt.Sprintf("--selector=%s=%s", k, v)) } - v, err := k8sClient(podNameCmd...) + v, err := k8sClient(ctx, podNameCmd...) if err != nil { return err } @@ -1452,7 +1517,7 @@ func SendMetricsRequest(ctx context.Context, serviceAccount string, endpoint str if err := json.Unmarshal([]byte(v), &pods); err != nil { return err } - token, err := k8sClient("create", "token", serviceAccount, "-n", sc.namespace) + token, err := k8sClient(ctx, "create", "token", serviceAccount, "-n", sc.namespace) if err != nil { return err } @@ -1460,7 +1525,7 @@ func SendMetricsRequest(ctx context.Context, serviceAccount string, endpoint str sc.metricsResponse = make(map[string]string) for _, p := range pods { if err := func() error { - addr, cleanup, err := portForward(p.Namespace, fmt.Sprintf("pod/%s", p.Name), mPort) + addr, cleanup, err := portForward(ctx, p.Namespace, fmt.Sprintf("pod/%s", p.Name), mPort) if err != nil { return err } @@ -1503,7 +1568,7 @@ func ScenarioCatalogIsDeleted(ctx context.Context, catalogUserName string) error if !ok { return fmt.Errorf("no catalog %q has been created for this scenario", catalogUserName) } - _, err := k8sClient("delete", "clustercatalog", catalogName, "--ignore-not-found=true", "--wait=true") + _, err := k8sClient(ctx, "delete", "clustercatalog", catalogName, "--ignore-not-found=true", "--wait=true") if err != nil { return fmt.Errorf("failed to delete catalog %q: %v", catalogUserName, err) } @@ -1547,7 +1612,7 @@ func ScenarioCatalogIsUpdatedToVersion(ctx context.Context, catalogUserName, ver if !ok { return fmt.Errorf("no catalog %q has been created for this scenario", catalogUserName) } - ref, err := k8sClient("get", "clustercatalog", catalogName, "-o", "jsonpath={.spec.source.image.ref}") + ref, err := k8sClient(ctx, "get", "clustercatalog", catalogName, "-o", "jsonpath={.spec.source.image.ref}") if err != nil { return err } @@ -1569,7 +1634,7 @@ func ScenarioCatalogIsUpdatedToVersion(ctx context.Context, catalogUserName, ver if err != nil { return err } - _, err = k8sClient("patch", "clustercatalog", catalogName, "--type", "merge", "-p", string(pb)) + _, err = k8sClient(ctx, "patch", "clustercatalog", catalogName, "--type", "merge", "-p", string(pb)) return err } @@ -1716,7 +1781,7 @@ spec: return fmt.Errorf("failed to marshal catalog YAML: %w", err) } - if _, err := k8scliWithInput(string(annotatedYAML), "apply", "-f", "-"); err != nil { + if _, err := k8scliWithInput(ctx, string(annotatedYAML), "apply", "-f", "-"); err != nil { return fmt.Errorf("failed to apply ClusterCatalog: %w", err) } @@ -1797,7 +1862,7 @@ func OperatorTargetNamespace(ctx context.Context, operator, namespace string) er sc := scenarioCtx(ctx) operator = substituteScenarioVars(operator, sc) namespace = substituteScenarioVars(namespace, sc) - raw, err := k8sClient("get", "deployment", "-n", sc.namespace, operator, "-o", "json") + raw, err := k8sClient(ctx, "get", "deployment", "-n", sc.namespace, operator, "-o", "json") if err != nil { return err } @@ -1806,9 +1871,15 @@ func OperatorTargetNamespace(ctx context.Context, operator, namespace string) er return err } - if tns := d.Spec.Template.Annotations["olm.targetNamespaces"]; tns != namespace { + tns := d.Spec.Template.Annotations["olm.targetNamespaces"] + if tns != namespace { return fmt.Errorf("expected target namespace %s, got %s", namespace, tns) } + + if rec := RecorderFromContext(ctx); rec != nil { + cmd := fmt.Sprintf("kubectl get deployment -n %s %s -o jsonpath='{.spec.template.metadata.annotations.olm\\.targetNamespaces}'", sc.namespace, operator) + rec.RecordCustom(cmd, tns+"\n", "") + } return nil } @@ -1816,7 +1887,7 @@ func OperatorTargetNamespace(ctx context.Context, operator, namespace string) er func MarkDeploymentReadiness(ctx context.Context, deploymentName, state string) error { sc := scenarioCtx(ctx) deploymentName = substituteScenarioVars(deploymentName, sc) - v, err := k8sClient("get", "deployment", "-n", sc.namespace, deploymentName, "-o", "jsonpath={.spec.selector.matchLabels}") + v, err := k8sClient(ctx, "get", "deployment", "-n", sc.namespace, deploymentName, "-o", "jsonpath={.spec.selector.matchLabels}") if err != nil { return err } @@ -1828,7 +1899,7 @@ func MarkDeploymentReadiness(ctx context.Context, deploymentName, state string) for k, v := range labels { podNameCmd = append(podNameCmd, fmt.Sprintf("--selector=%s=%s", k, v)) } - podName, err := k8sClient(podNameCmd...) + podName, err := k8sClient(ctx, podNameCmd...) if err != nil { return err } @@ -1841,13 +1912,13 @@ func MarkDeploymentReadiness(ctx context.Context, deploymentName, state string) default: return fmt.Errorf("invalid state %s", state) } - _, err = k8sClient("exec", podName, "-n", sc.namespace, "--", op, "/tmp/www/ready") + _, err = k8sClient(ctx, "exec", podName, "-n", sc.namespace, "--", op, "/tmp/www/ready") return err } // SetCRDFieldMinValue patches a CRD to set the minimum value for a field. // jsonPath is in the format ".spec.fieldName" and gets converted to the CRD schema path. -func SetCRDFieldMinValue(_ context.Context, resourceType, jsonPath string, minValue int) error { +func SetCRDFieldMinValue(ctx context.Context, resourceType, jsonPath string, minValue int) error { var crdName string switch resourceType { case "ClusterExtension": @@ -1868,7 +1939,7 @@ func SetCRDFieldMinValue(_ context.Context, resourceType, jsonPath string, minVa patchPath := fmt.Sprintf("/spec/versions/0/schema/openAPIV3Schema/%s/minimum", strings.Join(schemaParts, "/")) patch := fmt.Sprintf(`[{"op": "replace", "path": "%s", "value": %d}]`, patchPath, minValue) - _, err := k8sClient("patch", "crd", crdName, "--type=json", "-p", patch) + _, err := k8sClient(ctx, "patch", "crd", crdName, "--type=json", "-p", patch) return err } @@ -1905,7 +1976,7 @@ func extendMap(m map[string]string, keyValue ...string) map[string]string { } func getResource(kind string, name string, namespace string) (*unstructured.Unstructured, error) { - out, err := k8sClient("get", kind, name, "-n", namespace, "-o", "yaml") + out, err := k8sClient(context.Background(), "get", kind, name, "-n", namespace, "-o", "yaml") if err != nil { return nil, err } @@ -1948,7 +2019,7 @@ func listHelmReleaseResources(extName string) ([]client.Object, error) { // helmReleaseSecretForExtension returns the Helm release secret for the extension with name extName func helmReleaseSecretForExtension(extName string) (*corev1.Secret, error) { - out, err := k8sClient("get", "secrets", "-n", olmNamespace, + out, err := k8sClient(context.Background(), "get", "secrets", "-n", olmNamespace, "-l", fmt.Sprintf("name=%s,status=deployed", extName), "--field-selector", "type=operatorframework.io/index.v1", "-o", "json") if err != nil { @@ -2044,7 +2115,7 @@ func listExtensionRevisionResources(extName string) ([]client.Object, error) { // resolveObjectRef fetches an object from a Secret ref using kubectl. func resolveObjectRef(ref ocv1.ObjectSourceRef) (*unstructured.Unstructured, error) { - out, err := k8sClient("get", "secret", ref.Name, "-n", ref.Namespace, "-o", "json") + out, err := k8sClient(context.Background(), "get", "secret", ref.Name, "-n", ref.Namespace, "-o", "json") if err != nil { return nil, fmt.Errorf("getting Secret %s/%s: %w", ref.Namespace, ref.Name, err) } @@ -2083,7 +2154,7 @@ func resolveObjectRef(ref ocv1.ObjectSourceRef) (*unstructured.Unstructured, err // latestActiveRevisionForExtension returns the latest active revision for the extension called extName func latestActiveRevisionForExtension(extName string) (*ocv1.ClusterObjectSet, error) { - out, err := k8sClient("get", "clusterobjectsets", "-l", fmt.Sprintf("olm.operatorframework.io/owner-name=%s", extName), "-o", "json") + out, err := k8sClient(context.Background(), "get", "clusterobjectsets", "-l", fmt.Sprintf("olm.operatorframework.io/owner-name=%s", extName), "-o", "json") if err != nil { return nil, fmt.Errorf("error listing revisions for extension '%s': %w", extName, err) } @@ -2135,7 +2206,7 @@ func AnnotationsAreAdded(ctx context.Context, resourceName string, table *godog. args = append(args, fmt.Sprintf("%s=%s", k, v)) } args = append(args, "--overwrite", "-n", sc.namespace) - if _, err := k8sClient(args...); err != nil { + if _, err := k8sClient(ctx, args...); err != nil { return fmt.Errorf("failed to annotate %s: %w; stderr: %s", resourceName, err, stderrOutput(err)) } return nil @@ -2163,7 +2234,7 @@ func LabelsAreAdded(ctx context.Context, resourceName string, table *godog.Table args = append(args, fmt.Sprintf("%s=%s", k, v)) } args = append(args, "--overwrite", "-n", sc.namespace) - if _, err := k8sClient(args...); err != nil { + if _, err := k8sClient(ctx, args...); err != nil { return fmt.Errorf("failed to label %s: %w; stderr: %s", resourceName, err, stderrOutput(err)) } return nil @@ -2187,7 +2258,7 @@ func ResourceHasAnnotations(ctx context.Context, resourceName string, table *god } waitFor(ctx, func() bool { - out, err := k8sClient("get", kind, name, "-n", sc.namespace, "-o", "json") + out, err := k8sClient(ctx, "get", kind, name, "-n", sc.namespace, "-o", "json") if err != nil { return false } @@ -2236,7 +2307,7 @@ func ResourceHasLabels(ctx context.Context, resourceName string, table *godog.Ta } waitFor(ctx, func() bool { - out, err := k8sClient("get", kind, name, "-n", sc.namespace, "-o", "json") + out, err := k8sClient(ctx, "get", kind, name, "-n", sc.namespace, "-o", "json") if err != nil { return false } @@ -2331,7 +2402,7 @@ func RolloutRestartIsPerformed(ctx context.Context, resourceName string) error { // Run kubectl rollout restart to add the restart annotation. // This is the real command users run, so we test actual user behavior. - out, err := k8sClient("rollout", "restart", resourceName, "-n", sc.namespace) + out, err := k8sClient(ctx, "rollout", "restart", resourceName, "-n", sc.namespace) if err != nil { return fmt.Errorf("failed to rollout restart %s: %w; stderr: %s", resourceName, err, stderrOutput(err)) } @@ -2349,7 +2420,7 @@ func DeploymentPodTemplateHasAnnotation(ctx context.Context, deploymentName, ann deploymentName = substituteScenarioVars(deploymentName, sc) waitFor(ctx, func() bool { - out, err := k8sClient("get", "deployment", deploymentName, "-n", sc.namespace, "-o", "json") + out, err := k8sClient(ctx, "get", "deployment", deploymentName, "-n", sc.namespace, "-o", "json") if err != nil { return false } @@ -2379,7 +2450,7 @@ func DeploymentPodTemplateHasAnnotation(ctx context.Context, deploymentName, ann func TriggerClusterExtensionReconciliation(ctx context.Context) error { sc := scenarioCtx(ctx) - out, err := k8sClient("get", "clusterextension", sc.clusterExtensionName, "-o", "json") + out, err := k8sClient(ctx, "get", "clusterextension", sc.clusterExtensionName, "-o", "json") if err != nil { return fmt.Errorf("failed to get ClusterExtension %s: %w; stderr: %s", sc.clusterExtensionName, err, stderrOutput(err)) } @@ -2397,7 +2468,7 @@ func TriggerClusterExtensionReconciliation(ctx context.Context) error { } payload := fmt.Sprintf(`{"spec":{"install":{"preflight":{"crdUpgradeSafety":{"enforcement":%q}}}}}`, newEnforcement) - _, err = k8sClient("patch", "clusterextension", sc.clusterExtensionName, + _, err = k8sClient(ctx, "patch", "clusterextension", sc.clusterExtensionName, "--type=merge", "-p", payload) if err != nil { @@ -2413,7 +2484,7 @@ func DeploymentRolloutIsComplete(ctx context.Context, deploymentName string) err deploymentName = substituteScenarioVars(deploymentName, sc) waitFor(ctx, func() bool { - out, err := k8sClient("rollout", "status", "deployment/"+deploymentName, "-n", sc.namespace, "--watch=false") + out, err := k8sClient(ctx, "rollout", "status", "deployment/"+deploymentName, "-n", sc.namespace, "--watch=false") if err != nil { logger.V(1).Info("Failed to get rollout status", "deployment", deploymentName, "error", err) return false @@ -2436,7 +2507,7 @@ func DeploymentHasReplicaSets(ctx context.Context, deploymentName string, expect deploymentName = substituteScenarioVars(deploymentName, sc) waitFor(ctx, func() bool { - deploymentOut, err := k8sClient("get", "deployment", deploymentName, "-n", sc.namespace, "-o", "json") + deploymentOut, err := k8sClient(ctx, "get", "deployment", deploymentName, "-n", sc.namespace, "-o", "json") if err != nil { logger.V(1).Info("Failed to get deployment", "deployment", deploymentName, "error", err) return false @@ -2457,7 +2528,7 @@ func DeploymentHasReplicaSets(ctx context.Context, deploymentName string, expect args = append(args, "-l", strings.Join(parts, ",")) } args = append(args, "-o", "json") - out, err := k8sClient(args...) + out, err := k8sClient(ctx, args...) if err != nil { logger.V(1).Info("Failed to get ReplicaSets", "deployment", deploymentName, "error", err) return false diff --git a/test/e2e/steps/tls_steps.go b/test/e2e/steps/tls_steps.go index 66c381d59c..eac18b1202 100644 --- a/test/e2e/steps/tls_steps.go +++ b/test/e2e/steps/tls_steps.go @@ -60,7 +60,7 @@ var curveIDByName = map[string]tls.CurveID{ // The namespace is available via svc.Namespace. func getMetricsService(component string) (*corev1.Service, error) { serviceName := fmt.Sprintf("%s-service", component) - serviceNs, err := k8sClient("get", "service", "-A", "-o", + serviceNs, err := k8sClient(context.Background(), "get", "service", "-A", "-o", fmt.Sprintf(`jsonpath={.items[?(@.metadata.name=="%s")].metadata.namespace}`, serviceName)) if err != nil { return nil, fmt.Errorf("failed to find namespace for service %s: %w", serviceName, err) @@ -70,7 +70,7 @@ func getMetricsService(component string) (*corev1.Service, error) { return nil, fmt.Errorf("service %s not found in any namespace", serviceName) } - raw, err := k8sClient("get", "service", "-n", serviceNs, serviceName, "-o", "json") + raw, err := k8sClient(context.Background(), "get", "service", "-n", serviceNs, serviceName, "-o", "json") if err != nil { return nil, fmt.Errorf("failed to get service %s: %w", serviceName, err) } @@ -103,7 +103,7 @@ func randomAvailablePort() (int, error) { // portForward starts a kubectl port-forward to target (e.g. "service/foo" or "pod/bar") // in the given namespace, mapping a random local port to remotePort. It returns the // local address and a cleanup function. The caller is responsible for calling cleanup. -func portForward(ns, target string, remotePort int32) (string, func(), error) { +func portForward(ctx context.Context, ns, target string, remotePort int32) (string, func(), error) { localPort, err := randomAvailablePort() if err != nil { return "", nil, fmt.Errorf("failed to find a free local port: %w", err) @@ -123,6 +123,13 @@ func portForward(ns, target string, remotePort int32) (string, func(), error) { } } + if rec := RecorderFromContext(ctx); rec != nil { + rec.RecordCustom( + fmt.Sprintf("%s port-forward -n %s %s %d:%d &", k8sCli, ns, target, localPort, remotePort), + "", "", + ) + } + addr := fmt.Sprintf("127.0.0.1:%d", localPort) return addr, cleanup, nil } @@ -140,7 +147,7 @@ func withMetricsPortForward(ctx context.Context, component string, fn func(addr return err } - addr, cleanup, err := portForward(svc.Namespace, fmt.Sprintf("service/%s", svc.Name), port) + addr, cleanup, err := portForward(ctx, svc.Namespace, fmt.Sprintf("service/%s", svc.Name), port) if err != nil { return err } @@ -231,7 +238,7 @@ func componentDeploymentName(component string) (string, error) { // getDeploymentContainerArgs returns the args list of the container named "manager" // inside the named deployment. func getDeploymentContainerArgs(ns, name string) ([]string, error) { - raw, err := k8sClient("get", "deployment", name, "-n", ns, "-o", "json") + raw, err := k8sClient(context.Background(), "get", "deployment", name, "-n", ns, "-o", "json") if err != nil { return nil, fmt.Errorf("getting deployment %s/%s: %w", ns, name, err) } @@ -250,7 +257,7 @@ func getDeploymentContainerArgs(ns, name string) ([]string, error) { // getDeploymentContainerIndex returns the index of the container named "manager" // inside the named deployment. func getDeploymentContainerIndex(ns, name string) (int, error) { - raw, err := k8sClient("get", "deployment", name, "-n", ns, "-o", "json") + raw, err := k8sClient(context.Background(), "get", "deployment", name, "-n", ns, "-o", "json") if err != nil { return -1, fmt.Errorf("getting deployment %s/%s: %w", ns, name, err) } @@ -278,7 +285,7 @@ func patchDeploymentArgs(ns, name string, args []string) error { return err } patch := fmt.Sprintf(`[{"op":"replace","path":"/spec/template/spec/containers/%d/args","value":%s}]`, idx, string(argsJSON)) - _, err = k8sClient("patch", "deployment", name, "-n", ns, "--type=json", "-p", patch) + _, err = k8sClient(context.Background(), "patch", "deployment", name, "-n", ns, "--type=json", "-p", patch) return err } @@ -334,7 +341,7 @@ func configureDeploymentCustomTLS(ctx context.Context, component, version, ciphe } waitFor(ctx, func() bool { - _, err := k8sClient("rollout", "status", "-n", olmNamespace, + _, err := k8sClient(ctx, "rollout", "status", "-n", olmNamespace, fmt.Sprintf("deployment/%s", deploymentName), "--timeout=10s") return err == nil }) diff --git a/test/e2e/steps/upgrade_steps.go b/test/e2e/steps/upgrade_steps.go index 47aab69d2f..4267dccd69 100644 --- a/test/e2e/steps/upgrade_steps.go +++ b/test/e2e/steps/upgrade_steps.go @@ -85,7 +85,7 @@ func ComponentIsReadyToReconcile(ctx context.Context, component string) error { ns := namespaceForComponent(component) // Wait for deployment rollout to complete - depName, err := k8sClient("get", "deployments", "-n", ns, + depName, err := k8sClient(ctx, "get", "deployments", "-n", ns, "-l", fmt.Sprintf("app.kubernetes.io/name=%s", component), "-o", "jsonpath={.items[0].metadata.name}") if err != nil { @@ -94,7 +94,7 @@ func ComponentIsReadyToReconcile(ctx context.Context, component string) error { if depName == "" { return fmt.Errorf("failed to find deployment for component %s in namespace %s: no matching deployments found", component, ns) } - if _, err := k8sClient("rollout", "status", fmt.Sprintf("deployment/%s", depName), + if _, err := k8sClient(ctx, "rollout", "status", fmt.Sprintf("deployment/%s", depName), "-n", ns, fmt.Sprintf("--timeout=%s", timeout)); err != nil { return fmt.Errorf("deployment rollout failed for %s: %w", component, err) } @@ -107,7 +107,7 @@ func ComponentIsReadyToReconcile(ctx context.Context, component string) error { // Leader election can take up to LeaseDuration (137s) + RetryPeriod (26s) ≈ 163s in the worst case waitFor(ctx, func() bool { - output, err := k8sClient("get", "lease", leaseName, "-n", ns, + output, err := k8sClient(ctx, "get", "lease", leaseName, "-n", ns, "-o", "jsonpath={.spec.holderIdentity}") if err != nil || output == "" { return false @@ -130,9 +130,9 @@ var resourceTypeToComponent = map[string]string{ // reconcileEndingCheck returns a function that checks whether the leader pod's logs // contain a "reconcile ending" entry for the given resource name. -func reconcileEndingCheck(namespace, leaderPod, resourceName string) func() bool { +func reconcileEndingCheck(ctx context.Context, namespace, leaderPod, resourceName string) func() bool { return func() bool { - logs, err := k8sClient("logs", leaderPod, "-n", namespace, "--all-containers=true", "--tail=1000") + logs, err := k8sClient(ctx, "logs", leaderPod, "-n", namespace, "--all-containers=true", "--tail=1000") if err != nil { return false } @@ -161,15 +161,15 @@ func ClusterExtensionIsReconciled(ctx context.Context) error { } leaderPod := sc.leaderPods[component] - waitFor(ctx, reconcileEndingCheck(namespaceForComponent(component), leaderPod, resourceName)) + waitFor(ctx, reconcileEndingCheck(ctx, namespaceForComponent(component), leaderPod, resourceName)) return nil } // clusterCatalogUnpackedAfterPodCreation returns a check function that verifies the // ClusterCatalog is serving and its lastUnpacked timestamp is after the leader pod's creation. -func clusterCatalogUnpackedAfterPodCreation(namespace, resourceName, leaderPod string) func() bool { +func clusterCatalogUnpackedAfterPodCreation(ctx context.Context, namespace, resourceName, leaderPod string) func() bool { return func() bool { - catalogJSON, err := k8sClient("get", "clustercatalog", resourceName, "-o", "json") + catalogJSON, err := k8sClient(ctx, "get", "clustercatalog", resourceName, "-o", "json") if err != nil { return false } @@ -191,7 +191,7 @@ func clusterCatalogUnpackedAfterPodCreation(namespace, resourceName, leaderPod s return false } - podJSON, err := k8sClient("get", "pod", leaderPod, "-n", namespace, "-o", "json") + podJSON, err := k8sClient(ctx, "get", "pod", leaderPod, "-n", namespace, "-o", "json") if err != nil { return false } @@ -221,7 +221,7 @@ func allResourcesAreReconciled(ctx context.Context, resourceType string) error { // Discover all resources pluralType := strings.ToLower(resourceType) + "s" - output, err := k8sClient("get", pluralType, "-o", "jsonpath={.items[*].metadata.name}") + output, err := k8sClient(ctx, "get", pluralType, "-o", "jsonpath={.items[*].metadata.name}") if err != nil { return fmt.Errorf("failed to list %s resources: %w", resourceType, err) } @@ -232,7 +232,7 @@ func allResourcesAreReconciled(ctx context.Context, resourceType string) error { ns := namespaceForComponent(component) for _, name := range resourceNames { - waitFor(ctx, reconcileEndingCheck(ns, leaderPod, name)) + waitFor(ctx, reconcileEndingCheck(ctx, ns, leaderPod, name)) } return nil @@ -257,11 +257,11 @@ func ScenarioCatalogIsReconciled(ctx context.Context, catalogUserName string) er } ns := namespaceForComponent(component) - waitFor(ctx, reconcileEndingCheck(ns, leaderPod, catalogName)) + waitFor(ctx, reconcileEndingCheck(ctx, ns, leaderPod, catalogName)) // Also verify that lastUnpacked is after the leader pod's creation. // This mitigates flakiness caused by https://github.com/operator-framework/operator-controller/issues/1626 - waitFor(ctx, clusterCatalogUnpackedAfterPodCreation(ns, catalogName, leaderPod)) + waitFor(ctx, clusterCatalogUnpackedAfterPodCreation(ctx, ns, catalogName, leaderPod)) return nil } @@ -270,7 +270,22 @@ func ScenarioCatalogReportsCondition(ctx context.Context, catalogUserName, condi sc := scenarioCtx(ctx) catalogName, ok := sc.catalogs[catalogUserName] if !ok { - return fmt.Errorf("no catalog %q has been created for this scenario", catalogUserName) + if _, err := k8sClient(ctx, "get", "clustercatalog", catalogUserName); err != nil { + return fmt.Errorf("catalog %q was not created by this scenario and does not exist on the cluster", catalogUserName) + } + catalogName = catalogUserName + } + err := waitForCondition(ctx, "clustercatalog", catalogName, conditionType, conditionStatus, &conditionReason, nil) + if err == nil { + if rec := RecorderFromContext(ctx); rec != nil { + out, _ := k8sClient(ctx, "get", "clustercatalog", catalogName, + "-o", fmt.Sprintf("jsonpath={.status.conditions[?(@.type==\"%s\")]}", conditionType)) + rec.RecordCustom( + fmt.Sprintf("kubectl get clustercatalog %s -o jsonpath='{.status.conditions[?(@.type==\"%s\")]}' | jq .", + catalogName, conditionType), + out+"\n", "", + ) + } } - return waitForCondition(ctx, "clustercatalog", catalogName, conditionType, conditionStatus, &conditionReason, nil) + return err }