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 }