├── .bingo ├── .gitignore ├── README.md ├── Variables.mk ├── bingo.mod ├── bingo.sum ├── go.mod ├── golangci-lint.mod ├── golangci-lint.sum ├── goreleaser.mod ├── goreleaser.sum └── variables.env ├── .github ├── dependabot.yml └── workflows │ ├── add-to-project.yaml │ ├── ci.yml │ ├── go-apidiff.yml │ └── release.yml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yml ├── .krew.yaml ├── LICENSE ├── Makefile ├── OWNERS ├── README.md ├── assets └── demo │ ├── demo.gif │ ├── demo.sh │ └── gen-demo.sh ├── go.mod ├── go.sum ├── internal ├── cmd │ ├── catalog.go │ ├── catalog_add.go │ ├── catalog_list.go │ ├── catalog_remove.go │ ├── internal │ │ ├── log │ │ │ └── log.go │ │ └── olmv1 │ │ │ ├── action_suite_test.go │ │ │ ├── catalog_create.go │ │ │ ├── catalog_delete.go │ │ │ ├── catalog_installed_get.go │ │ │ ├── catalog_update.go │ │ │ ├── extension_delete.go │ │ │ ├── extension_install.go │ │ │ ├── extension_installed_get.go │ │ │ ├── extension_update.go │ │ │ ├── printing.go │ │ │ └── printing_test.go │ ├── olmv1.go │ ├── operator_describe.go │ ├── operator_install.go │ ├── operator_list.go │ ├── operator_list_available.go │ ├── operator_list_operands.go │ ├── operator_uninstall.go │ ├── operator_upgrade.go │ ├── root.go │ └── version.go ├── pkg │ ├── action │ │ ├── action_suite_test.go │ │ ├── catalog_add.go │ │ ├── catalog_list.go │ │ ├── catalog_remove.go │ │ ├── constants.go │ │ ├── helpers.go │ │ ├── operator_install.go │ │ ├── operator_list.go │ │ ├── operator_list_available.go │ │ ├── operator_uninstall.go │ │ ├── operator_uninstall_test.go │ │ └── operator_upgrade.go │ ├── catalogsource │ │ └── catalogsource.go │ ├── operand │ │ └── strategy.go │ ├── operator │ │ └── package.go │ ├── subscription │ │ └── subscription.go │ └── v1 │ │ └── action │ │ ├── action_suite_test.go │ │ ├── catalog_create.go │ │ ├── catalog_create_test.go │ │ ├── catalog_delete.go │ │ ├── catalog_delete_test.go │ │ ├── catalog_installed_get.go │ │ ├── catalog_installed_get_test.go │ │ ├── catalog_update.go │ │ ├── catalog_update_test.go │ │ ├── errors.go │ │ ├── extension_delete.go │ │ ├── extension_delete_test.go │ │ ├── extension_install.go │ │ ├── extension_install_test.go │ │ ├── extension_installed_get.go │ │ ├── extension_installed_get_test.go │ │ ├── extension_update.go │ │ ├── extension_update_test.go │ │ ├── helpers.go │ │ └── interfaces.go └── version │ └── version.go ├── main.go └── pkg └── action ├── action_suite_test.go ├── config.go ├── operator_list_operands.go └── operator_list_operands_test.go /.bingo/.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Ignore everything 3 | * 4 | 5 | # But not these files: 6 | !.gitignore 7 | !*.mod 8 | !*.sum 9 | !README.md 10 | !Variables.mk 11 | !variables.env 12 | 13 | *tmp.mod 14 | -------------------------------------------------------------------------------- /.bingo/README.md: -------------------------------------------------------------------------------- 1 | # Project Development Dependencies. 2 | 3 | This is directory which stores Go modules with pinned buildable package that is used within this repository, managed by https://github.com/bwplotka/bingo. 4 | 5 | * Run `bingo get` to install all tools having each own module file in this directory. 6 | * Run `bingo get ` to install that have own module file in this directory. 7 | * For Makefile: Make sure to put `include .bingo/Variables.mk` in your Makefile, then use $() variable where is the .bingo/.mod. 8 | * For shell: Run `source .bingo/variables.env` to source all environment variable for each tool. 9 | * For go: Import `.bingo/variables.go` to for variable names. 10 | * See https://github.com/bwplotka/bingo or -h on how to add, remove or change binaries dependencies. 11 | 12 | ## Requirements 13 | 14 | * Go 1.14+ 15 | -------------------------------------------------------------------------------- /.bingo/Variables.mk: -------------------------------------------------------------------------------- 1 | # Auto generated binary variables helper managed by https://github.com/bwplotka/bingo v0.9. DO NOT EDIT. 2 | # All tools are designed to be build inside $GOBIN. 3 | BINGO_DIR := $(dir $(lastword $(MAKEFILE_LIST))) 4 | GOPATH ?= $(shell go env GOPATH) 5 | GOBIN ?= $(firstword $(subst :, ,${GOPATH}))/bin 6 | GO ?= $(shell which go) 7 | 8 | # Below generated variables ensure that every time a tool under each variable is invoked, the correct version 9 | # will be used; reinstalling only if needed. 10 | # For example for bingo variable: 11 | # 12 | # In your main Makefile (for non array binaries): 13 | # 14 | #include .bingo/Variables.mk # Assuming -dir was set to .bingo . 15 | # 16 | #command: $(BINGO) 17 | # @echo "Running bingo" 18 | # @$(BINGO) 19 | # 20 | BINGO := $(GOBIN)/bingo-v0.9.0 21 | $(BINGO): $(BINGO_DIR)/bingo.mod 22 | @# Install binary/ries using Go 1.14+ build command. This is using bwplotka/bingo-controlled, separate go module with pinned dependencies. 23 | @echo "(re)installing $(GOBIN)/bingo-v0.9.0" 24 | @cd $(BINGO_DIR) && GOWORK=off $(GO) build -mod=mod -modfile=bingo.mod -o=$(GOBIN)/bingo-v0.9.0 "github.com/bwplotka/bingo" 25 | 26 | GOLANGCI_LINT := $(GOBIN)/golangci-lint-v1.63.4 27 | $(GOLANGCI_LINT): $(BINGO_DIR)/golangci-lint.mod 28 | @# Install binary/ries using Go 1.14+ build command. This is using bwplotka/bingo-controlled, separate go module with pinned dependencies. 29 | @echo "(re)installing $(GOBIN)/golangci-lint-v1.63.4" 30 | @cd $(BINGO_DIR) && GOWORK=off $(GO) build -mod=mod -modfile=golangci-lint.mod -o=$(GOBIN)/golangci-lint-v1.63.4 "github.com/golangci/golangci-lint/cmd/golangci-lint" 31 | 32 | GORELEASER := $(GOBIN)/goreleaser-v1.26.2 33 | $(GORELEASER): $(BINGO_DIR)/goreleaser.mod 34 | @# Install binary/ries using Go 1.14+ build command. This is using bwplotka/bingo-controlled, separate go module with pinned dependencies. 35 | @echo "(re)installing $(GOBIN)/goreleaser-v1.26.2" 36 | @cd $(BINGO_DIR) && GOWORK=off $(GO) build -mod=mod -modfile=goreleaser.mod -o=$(GOBIN)/goreleaser-v1.26.2 "github.com/goreleaser/goreleaser" 37 | 38 | -------------------------------------------------------------------------------- /.bingo/bingo.mod: -------------------------------------------------------------------------------- 1 | module _ // Auto generated by https://github.com/bwplotka/bingo. DO NOT EDIT 2 | 3 | go 1.20 4 | 5 | require github.com/bwplotka/bingo v0.9.0 6 | -------------------------------------------------------------------------------- /.bingo/bingo.sum: -------------------------------------------------------------------------------- 1 | github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= 2 | github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= 3 | github.com/bwplotka/bingo v0.9.0 h1:slnsdJYExR4iRalHR6/ZiYnr9vSazOuFGmc2LdX293g= 4 | github.com/bwplotka/bingo v0.9.0/go.mod h1:GxC/y/xbmOK5P29cn+B3HuOSw0s2gruddT3r+rDizDw= 5 | github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 6 | github.com/efficientgo/core v1.0.0-rc.0 h1:jJoA0N+C4/knWYVZ6GrdHOtDyrg8Y/TR4vFpTaqTsqs= 7 | github.com/efficientgo/core v1.0.0-rc.0/go.mod h1:kQa0V74HNYMfuJH6jiPiwNdpWXl4xd/K4tzlrcvYDQI= 8 | github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= 9 | github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc= 10 | github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 11 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 12 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 13 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 14 | github.com/spf13/cobra v1.5.0 h1:X+jTBEBqF0bHN+9cSMgmfuvv2VHJ9ezmFNf9Y/XstYU= 15 | github.com/spf13/cobra v1.5.0/go.mod h1:dWXEIy2H428czQCjInthrTRUg7yKbok+2Qi/yBIJoUM= 16 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 17 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 18 | golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= 19 | golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 20 | golang.org/x/sync v0.2.0 h1:PUR+T4wwASmuSTYdKjYHI5TD22Wy5ogLU5qZCOLxBrI= 21 | golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 22 | golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= 23 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 24 | golang.org/x/term v0.8.0 h1:n5xxQn2i3PC0yLAbjTpNT85q/Kgzcr2gIoX9OrJUols= 25 | golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 26 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 27 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 28 | mvdan.cc/sh/v3 v3.7.0 h1:lSTjdP/1xsddtaKfGg7Myu7DnlHItd3/M2tomOcNNBg= 29 | mvdan.cc/sh/v3 v3.7.0/go.mod h1:K2gwkaesF/D7av7Kxl0HbF5kGOd2ArupNTX3X44+8l8= 30 | -------------------------------------------------------------------------------- /.bingo/go.mod: -------------------------------------------------------------------------------- 1 | module _ // Fake go.mod auto-created by 'bingo' for go -moddir compatibility with non-Go projects. Commit this file, together with other .mod files. -------------------------------------------------------------------------------- /.bingo/golangci-lint.mod: -------------------------------------------------------------------------------- 1 | module _ // Auto generated by https://github.com/bwplotka/bingo. DO NOT EDIT 2 | 3 | go 1.22.1 4 | 5 | toolchain go1.22.5 6 | 7 | require github.com/golangci/golangci-lint v1.63.4 // cmd/golangci-lint 8 | -------------------------------------------------------------------------------- /.bingo/goreleaser.mod: -------------------------------------------------------------------------------- 1 | module _ // Auto generated by https://github.com/bwplotka/bingo. DO NOT EDIT 2 | 3 | go 1.22 4 | 5 | toolchain go1.22.5 6 | 7 | require github.com/goreleaser/goreleaser v1.26.2 8 | -------------------------------------------------------------------------------- /.bingo/variables.env: -------------------------------------------------------------------------------- 1 | # Auto generated binary variables helper managed by https://github.com/bwplotka/bingo v0.9. DO NOT EDIT. 2 | # All tools are designed to be build inside $GOBIN. 3 | # Those variables will work only until 'bingo get' was invoked, or if tools were installed via Makefile's Variables.mk. 4 | GOBIN=${GOBIN:=$(go env GOBIN)} 5 | 6 | if [ -z "$GOBIN" ]; then 7 | GOBIN="$(go env GOPATH)/bin" 8 | fi 9 | 10 | 11 | BINGO="${GOBIN}/bingo-v0.9.0" 12 | 13 | GOLANGCI_LINT="${GOBIN}/golangci-lint-v1.63.4" 14 | 15 | GORELEASER="${GOBIN}/goreleaser-v1.26.2" 16 | 17 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | - package-ecosystem: "gomod" 8 | directory: "/" 9 | schedule: 10 | interval: "daily" 11 | -------------------------------------------------------------------------------- /.github/workflows/add-to-project.yaml: -------------------------------------------------------------------------------- 1 | name: Add epic issues to OLMv1 project 2 | 3 | on: 4 | issues: 5 | types: 6 | - opened 7 | - labeled 8 | 9 | jobs: 10 | add-to-project: 11 | name: Add issue to project 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/add-to-project@v1.0.2 15 | with: 16 | project-url: https://github.com/orgs/operator-framework/projects/8 17 | github-token: ${{ secrets.ADD_TO_PROJECT_PAT }} 18 | labeled: epic 19 | label-operator: OR 20 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | 2 | name: test 3 | on: 4 | push: 5 | pull_request: 6 | merge_group: 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v4 13 | with: 14 | fetch-depth: 0 15 | 16 | - name: Setup Go 17 | uses: actions/setup-go@v5 18 | with: 19 | go-version-file: go.mod 20 | 21 | - name: Test 22 | run: make test 23 | 24 | - name: Lint 25 | run: make lint 26 | -------------------------------------------------------------------------------- /.github/workflows/go-apidiff.yml: -------------------------------------------------------------------------------- 1 | name: go-apidiff 2 | on: [ pull_request ] 3 | jobs: 4 | go-apidiff: 5 | if: github.event_name == 'pull_request' 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v4 9 | with: 10 | fetch-depth: 0 11 | - uses: actions/setup-go@v5 12 | with: 13 | go-version-file: go.mod 14 | - uses: joelanford/go-apidiff@main 15 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | push: 4 | branches: 5 | - 'main' 6 | tags: 7 | - 'v*.*.*' 8 | pull_request: 9 | branches: 10 | - 'main' 11 | merge_group: 12 | 13 | jobs: 14 | release: 15 | runs-on: ubuntu-latest 16 | steps: 17 | 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | with: 21 | fetch-depth: 0 22 | 23 | - name: Setup Go 24 | uses: actions/setup-go@v5 25 | with: 26 | go-version-file: go.mod 27 | 28 | - name: Set the release related variables 29 | if: startsWith(github.ref, 'refs/tags/v') 30 | run: | 31 | echo RELEASE_ARGS="--clean" >> $GITHUB_ENV 32 | echo ENABLE_RELEASE_PIPELINE=true >> $GITHUB_ENV 33 | 34 | - name: Run GoReleaser 35 | run: make release 36 | env: 37 | GITHUB_TOKEN: ${{ github.token }} 38 | 39 | - name: Update new version in krew-index 40 | if: startsWith(github.ref, 'refs/tags/v') 41 | uses: rajatjindal/krew-release-bot@v0.0.47 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /bin 2 | 3 | ### mac 4 | .DS_Store 5 | 6 | ### Goland IDEA 7 | .idea/ 8 | 9 | ### gorelease 10 | dist/ 11 | 12 | ### VS Code 13 | .vscode/ 14 | .vscode/* 15 | !.vscode/settings.json 16 | !.vscode/tasks.json 17 | !.vscode/launch.json 18 | !.vscode/extensions.json 19 | 20 | 21 | ### vim 22 | # Swap 23 | [._]*.s[a-v][a-z] 24 | [._]*.sw[a-p] 25 | [._]s[a-rt-v][a-z] 26 | [._]ss[a-gi-z] 27 | [._]sw[a-p] 28 | 29 | # Session 30 | Session.vim 31 | 32 | #asciicinema 33 | assets/demo/demo.asciinema.json 34 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | ######## 2 | # NOTE 3 | # 4 | # This file is duplicated in the following repos: 5 | # - operator-framework/kubectl-operator 6 | # - operator-framework/catalogd 7 | # - operator-framework/operator-controller 8 | # - operator-framework/rukpak 9 | # 10 | # If you are making a change, please make it in ALL 11 | # of the above repositories! 12 | # 13 | # TODO: Find a way to have a shared golangci config. 14 | ######## 15 | 16 | run: 17 | # Default timeout is 1m, up to give more room 18 | timeout: 4m 19 | 20 | linters: 21 | enable: 22 | - asciicheck 23 | - bodyclose 24 | - errorlint 25 | - gci 26 | - gofmt 27 | - gosec 28 | - importas 29 | - misspell 30 | - nestif 31 | - nonamedreturns 32 | - prealloc 33 | - stylecheck 34 | - tparallel 35 | - unconvert 36 | - unparam 37 | - unused 38 | - whitespace 39 | 40 | linters-settings: 41 | gci: 42 | sections: 43 | - standard 44 | - dot 45 | - default 46 | - prefix(github.com/operator-framework) 47 | 48 | # TODO: change this to `localmodule` when golangci-lint 49 | # is updated to 1.58+ 50 | - prefix(github.com/operator-framework/kubectl-operator) 51 | custom-order: true 52 | 53 | errorlint: 54 | errorf: false 55 | 56 | importas: 57 | alias: 58 | - pkg: k8s.io/apimachinery/pkg/apis/meta/v1 59 | alias: metav1 60 | - pkg: k8s.io/apimachinery/pkg/api/errors 61 | alias: apierrors 62 | - pkg: k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1 63 | alias: apiextensionsv1 64 | - pkg: k8s.io/apimachinery/pkg/util/runtime 65 | alias: utilruntime 66 | - pkg: "^k8s\\.io/api/([^/]+)/(v[^/]+)$" 67 | alias: $1$2 68 | - pkg: sigs.k8s.io/controller-runtime 69 | alias: ctrl 70 | - pkg: github.com/operator-framework/rukpak/api/v1alpha2 71 | alias: rukpakv1alpha2 72 | - pkg: github.com/blang/semver/v4 73 | alias: bsemver 74 | 75 | output: 76 | formats: 77 | - format: tab 78 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - go mod download 4 | builds: 5 | - id: kubectl-operator 6 | main: ./ 7 | binary: kubectl-operator 8 | env: 9 | - CGO_ENABLED=0 10 | asmflags: 11 | - all=-trimpath={{ dir .Env.PWD }} 12 | gcflags: 13 | - all=-trimpath={{ dir .Env.PWD }} 14 | ldflags: 15 | - -s 16 | - -w 17 | - -X github.com/operator-framework/kubectl-operator/internal/version.GitVersion={{.Env.GIT_VERSION}} 18 | - -X github.com/operator-framework/kubectl-operator/internal/version.GitCommit={{.Env.GIT_COMMIT}} 19 | - -X github.com/operator-framework/kubectl-operator/internal/version.GitCommitTime={{.Env.GIT_COMMIT_TIME}} 20 | - -X github.com/operator-framework/kubectl-operator/internal/version.GitTreeState={{.Env.GIT_TREE_STATE}} 21 | targets: 22 | - darwin_amd64 23 | - darwin_arm64 24 | - linux_amd64 25 | - linux_arm64 26 | - windows_amd64 27 | 28 | checksum: 29 | name_template: "{{ .ProjectName }}_v{{ .Version }}_checksums.txt" 30 | 31 | archives: 32 | - builds: 33 | - kubectl-operator 34 | name_template: "{{ .ProjectName }}_{{ .Tag }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}" 35 | wrap_in_directory: false 36 | format: tar.gz 37 | files: 38 | - LICENSE 39 | 40 | release: 41 | disable: '{{ ne .Env.ENABLE_RELEASE_PIPELINE "true" }}' 42 | -------------------------------------------------------------------------------- /.krew.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: krew.googlecontainertools.github.com/v1alpha2 2 | kind: Plugin 3 | metadata: 4 | name: operator 5 | spec: 6 | version: {{ .TagName }} 7 | homepage: https://github.com/operator-framework/kubectl-operator 8 | shortDescription: Manage operators with Operator Lifecycle Manager 9 | description: | 10 | This plugin is a package manager for operators in your cluster. It 11 | simplifies adding and removing operator catalogs, and it has familiar 12 | commands for installing, uninstalling, and listing available and 13 | installed operators. 14 | 15 | One example of a catalog is the public operatorhub.io index, which 16 | is installed by default with Operator Lifecycle Manager. 17 | caveats: | 18 | * This plugin requires Operator Lifecycle Manager to be installed in your 19 | cluster. See the installation instructions at 20 | https://olm.operatorframework.io/docs/getting-started/ 21 | platforms: 22 | - selector: 23 | matchLabels: 24 | os: darwin 25 | arch: amd64 26 | {{addURIAndSha "https://github.com/operator-framework/kubectl-operator/releases/download/{{ .TagName }}/kubectl-operator_{{ .TagName }}_darwin_amd64.tar.gz" .TagName }} 27 | bin: kubectl-operator 28 | - selector: 29 | matchLabels: 30 | os: darwin 31 | arch: arm64 32 | {{addURIAndSha "https://github.com/operator-framework/kubectl-operator/releases/download/{{ .TagName }}/kubectl-operator_{{ .TagName }}_darwin_arm64.tar.gz" .TagName }} 33 | bin: kubectl-operator 34 | - selector: 35 | matchLabels: 36 | os: linux 37 | arch: amd64 38 | {{addURIAndSha "https://github.com/operator-framework/kubectl-operator/releases/download/{{ .TagName }}/kubectl-operator_{{ .TagName }}_linux_amd64.tar.gz" .TagName }} 39 | bin: kubectl-operator 40 | - selector: 41 | matchLabels: 42 | os: linux 43 | arch: arm64 44 | {{addURIAndSha "https://github.com/operator-framework/kubectl-operator/releases/download/{{ .TagName }}/kubectl-operator_{{ .TagName }}_linux_arm64.tar.gz" .TagName }} 45 | bin: kubectl-operator 46 | - selector: 47 | matchLabels: 48 | os: windows 49 | arch: amd64 50 | {{addURIAndSha "https://github.com/operator-framework/kubectl-operator/releases/download/{{ .TagName }}/kubectl-operator_{{ .TagName }}_windows_amd64.tar.gz" .TagName }} 51 | bin: kubectl-operator.exe 52 | 53 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := build 2 | 3 | SHELL:=/bin/bash 4 | 5 | export GIT_VERSION = $(shell git describe --tags --always) 6 | export GIT_COMMIT = $(shell git rev-parse HEAD) 7 | export GIT_COMMIT_TIME = $(shell TZ=UTC git show -s --format=%cd --date=format-local:%Y-%m-%dT%TZ) 8 | export GIT_TREE_STATE = $(shell sh -c '(test -n "$(shell git status -s)" && echo "dirty") || echo "clean"') 9 | export CGO_ENABLED = 1 10 | 11 | # bingo manages consistent tooling versions for things like kind, kustomize, etc. 12 | include .bingo/Variables.mk 13 | 14 | REPO = $(shell go list -m) 15 | GO_BUILD_ARGS = \ 16 | -gcflags "all=-trimpath=$(shell dirname $(shell pwd))" \ 17 | -asmflags "all=-trimpath=$(shell dirname $(shell pwd))" \ 18 | -ldflags " \ 19 | -s \ 20 | -w \ 21 | -X '$(REPO)/internal/version.GitVersion=$(GIT_VERSION)' \ 22 | -X '$(REPO)/internal/version.GitCommit=$(GIT_COMMIT)' \ 23 | -X '$(REPO)/internal/version.GitCommitTime=$(GIT_COMMIT_TIME)' \ 24 | -X '$(REPO)/internal/version.GitTreeState=$(GIT_TREE_STATE)' \ 25 | " \ 26 | 27 | .PHONY: vet 28 | vet: 29 | go vet ./... 30 | 31 | .PHONY: fmt 32 | fmt: 33 | go fmt ./... 34 | 35 | 36 | .PHONY: bingo-upgrade 37 | bingo-upgrade: $(BINGO) #EXHELP Upgrade tools 38 | @for pkg in $$($(BINGO) list | awk '{ print $$3 }' | tail -n +3 | sed 's/@.*//'); do \ 39 | echo -e "Upgrading \033[35m$$pkg\033[0m to latest..."; \ 40 | $(BINGO) get "$$pkg@latest"; \ 41 | done 42 | 43 | .PHONY: build 44 | build: vet fmt 45 | go build $(GO_BUILD_ARGS) -o bin/kubectl-operator 46 | 47 | .PHONY: test 48 | test: 49 | go test ./... 50 | 51 | .PHONY: install 52 | install: build 53 | install bin/kubectl-operator $(shell go env GOPATH)/bin 54 | 55 | .PHONY: gen-demo 56 | gen-demo: 57 | ./assets/demo/gen-demo.sh 58 | 59 | .PHONY: lint 60 | lint: $(GOLANGCI_LINT) 61 | $(GOLANGCI_LINT) --timeout 3m run 62 | 63 | .PHONY: fix-lint-issues 64 | lint-fix: $(GOLANGCI_LINT) #HELP Run golangci linter. 65 | $(GOLANGCI_LINT) run --fix --timeout 3m 66 | 67 | .PHONY: release 68 | RELEASE_ARGS?=release --clean --snapshot 69 | release: $(GORELEASER) 70 | $(GORELEASER) $(RELEASE_ARGS) 71 | -------------------------------------------------------------------------------- /OWNERS: -------------------------------------------------------------------------------- 1 | approvers: 2 | - joelanford 3 | reviewers: 4 | - exdx 5 | - benluddy 6 | - joelanford 7 | - jmrodri 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # kubectl operator 2 | 3 | `kubectl operator` is a kubectl plugin that functions as a package manager 4 | for Operators in your cluster. It simplifies adding and removing Operator 5 | catalogs, and it has familiar commands for installing, uninstalling, and 6 | listing available and installed Operators. 7 | 8 | 9 | > **NOTE**: This plugin requires Operator Lifecycle Manager to be installed in your 10 | cluster. See the OLM installation instructions [here](https://olm.operatorframework.io/docs/getting-started/) 11 | 12 | ## Demo 13 | 14 | ![asciicast](assets/demo/demo.gif) 15 | 16 | ## Install 17 | 18 | The `kubectl operator` plugin is distributed via [`krew`](https://krew.sigs.k8s.io/). To install it, run the following: 19 | ```console 20 | kubectl krew install operator 21 | ``` 22 | -------------------------------------------------------------------------------- /assets/demo/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/operator-framework/kubectl-operator/8066c7311c7634a3d890d630f89c85b16afc66f6/assets/demo/demo.gif -------------------------------------------------------------------------------- /assets/demo/demo.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | INTERACTIVE=${INTERACTIVE:-"1"} 4 | 5 | run() { 6 | type "kubectl operator catalog list -A" 7 | type "kubectl operator list-available prometheus" 8 | type "kubectl operator install prometheus --create-operator-group -v 0.32.0 -c beta" 9 | type "kubectl operator list" 10 | type "kubectl operator upgrade prometheus" 11 | type "kubectl operator list" 12 | type "kubectl operator uninstall prometheus --delete-crds --delete-operator-groups" 13 | } 14 | 15 | prompt() { 16 | echo "" 17 | echo -n "$ " 18 | } 19 | 20 | type() { 21 | prompt 22 | sleep 1 23 | for (( i=0; i<${#1}; i++ )); do 24 | echo -n "${1:$i:1}" 25 | sleep 0.06 26 | done 27 | echo "" 28 | sleep 0.25 29 | eval $1 30 | [[ "$INTERACTIVE" == "1" ]] && read -p "" 31 | } 32 | 33 | run 34 | -------------------------------------------------------------------------------- /assets/demo/gen-demo.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o errexit 4 | set -o pipefail 5 | 6 | INTERACTIVE=0 asciinema rec --overwrite -c ./assets/demo/demo.sh ./assets/demo/demo.asciinema.json 7 | asciicast2gif -w 102 -h 34 ./assets/demo/demo.asciinema.json ./assets/demo/demo.gif 8 | rm ./assets/demo/demo.asciinema.json 9 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/operator-framework/kubectl-operator 2 | 3 | go 1.23.4 4 | 5 | require ( 6 | github.com/blang/semver/v4 v4.0.0 7 | github.com/containerd/containerd v1.7.26 8 | github.com/containerd/platforms v0.2.1 9 | github.com/onsi/ginkgo v1.16.5 10 | github.com/onsi/gomega v1.36.2 11 | github.com/opencontainers/image-spec v1.1.1 12 | github.com/operator-framework/api v0.30.0 13 | github.com/operator-framework/operator-controller v1.2.0 14 | github.com/operator-framework/operator-lifecycle-manager v0.23.1 15 | github.com/operator-framework/operator-registry v1.50.0 16 | github.com/sirupsen/logrus v1.9.3 17 | github.com/spf13/cobra v1.9.1 18 | github.com/spf13/pflag v1.0.6 19 | k8s.io/api v0.32.2 20 | k8s.io/apiextensions-apiserver v0.32.2 21 | k8s.io/apimachinery v0.32.2 22 | k8s.io/client-go v0.32.2 23 | sigs.k8s.io/controller-runtime v0.20.2 24 | sigs.k8s.io/yaml v1.4.0 25 | ) 26 | 27 | require ( 28 | cel.dev/expr v0.18.0 // indirect 29 | github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 // indirect 30 | github.com/BurntSushi/toml v1.4.0 // indirect 31 | github.com/Microsoft/go-winio v0.6.2 // indirect 32 | github.com/Microsoft/hcsshim v0.12.9 // indirect 33 | github.com/antlr4-go/antlr/v4 v4.13.0 // indirect 34 | github.com/containerd/cgroups/v3 v3.0.3 // indirect 35 | github.com/containerd/containerd/api v1.8.0 // indirect 36 | github.com/containerd/continuity v0.4.4 // indirect 37 | github.com/containerd/errdefs v0.3.0 // indirect 38 | github.com/containerd/errdefs/pkg v0.3.0 // indirect 39 | github.com/containerd/log v0.1.0 // indirect 40 | github.com/containerd/ttrpc v1.2.7 // indirect 41 | github.com/containerd/typeurl/v2 v2.2.0 // indirect 42 | github.com/containers/common v0.61.0 // indirect 43 | github.com/containers/image/v5 v5.33.1 // indirect 44 | github.com/containers/libtrust v0.0.0-20230121012942-c1716e8a8d01 // indirect 45 | github.com/containers/ocicrypt v1.2.0 // indirect 46 | github.com/containers/storage v1.56.1 // indirect 47 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 48 | github.com/distribution/reference v0.6.0 // indirect 49 | github.com/docker/cli v27.5.0+incompatible // indirect 50 | github.com/docker/distribution v2.8.3+incompatible // indirect 51 | github.com/docker/docker v27.5.0+incompatible // indirect 52 | github.com/docker/docker-credential-helpers v0.8.2 // indirect 53 | github.com/docker/go-connections v0.5.0 // indirect 54 | github.com/docker/go-units v0.5.0 // indirect 55 | github.com/emicklei/go-restful/v3 v3.12.1 // indirect 56 | github.com/evanphx/json-patch/v5 v5.9.11 // indirect 57 | github.com/felixge/httpsnoop v1.0.4 // indirect 58 | github.com/fsnotify/fsnotify v1.8.0 // indirect 59 | github.com/fxamacker/cbor/v2 v2.7.0 // indirect 60 | github.com/go-logr/logr v1.4.2 // indirect 61 | github.com/go-logr/stdr v1.2.2 // indirect 62 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 63 | github.com/go-openapi/jsonreference v0.21.0 // indirect 64 | github.com/go-openapi/swag v0.23.0 // indirect 65 | github.com/gogo/protobuf v1.3.2 // indirect 66 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 67 | github.com/golang/protobuf v1.5.4 // indirect 68 | github.com/google/cel-go v0.22.1 // indirect 69 | github.com/google/gnostic-models v0.6.9 // indirect 70 | github.com/google/go-cmp v0.7.0 // indirect 71 | github.com/google/gofuzz v1.2.0 // indirect 72 | github.com/google/uuid v1.6.0 // indirect 73 | github.com/gorilla/mux v1.8.1 // indirect 74 | github.com/h2non/filetype v1.1.3 // indirect 75 | github.com/h2non/go-is-svg v0.0.0-20160927212452-35e8c4b0612c // indirect 76 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 77 | github.com/josharian/intern v1.0.0 // indirect 78 | github.com/json-iterator/go v1.1.12 // indirect 79 | github.com/klauspost/compress v1.18.0 // indirect 80 | github.com/mailru/easyjson v0.9.0 // indirect 81 | github.com/moby/locker v1.0.1 // indirect 82 | github.com/moby/sys/capability v0.3.0 // indirect 83 | github.com/moby/sys/mountinfo v0.7.2 // indirect 84 | github.com/moby/sys/sequential v0.5.0 // indirect 85 | github.com/moby/sys/user v0.3.0 // indirect 86 | github.com/moby/sys/userns v0.1.0 // indirect 87 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 88 | github.com/modern-go/reflect2 v1.0.2 // indirect 89 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 90 | github.com/nxadm/tail v1.4.8 // indirect 91 | github.com/opencontainers/go-digest v1.0.0 // indirect 92 | github.com/opencontainers/runtime-spec v1.2.0 // indirect 93 | github.com/pkg/errors v0.9.1 // indirect 94 | github.com/stoewer/go-strcase v1.3.0 // indirect 95 | github.com/x448/float16 v0.8.4 // indirect 96 | go.etcd.io/bbolt v1.3.11 // indirect 97 | go.opencensus.io v0.24.0 // indirect 98 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 99 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect 100 | go.opentelemetry.io/otel v1.33.0 // indirect 101 | go.opentelemetry.io/otel/metric v1.33.0 // indirect 102 | go.opentelemetry.io/otel/trace v1.33.0 // indirect 103 | golang.org/x/exp v0.0.0-20250228200357-dead58393ab7 // indirect 104 | golang.org/x/net v0.35.0 // indirect 105 | golang.org/x/oauth2 v0.27.0 // indirect 106 | golang.org/x/sync v0.11.0 // indirect 107 | golang.org/x/sys v0.30.0 // indirect 108 | golang.org/x/term v0.29.0 // indirect 109 | golang.org/x/text v0.22.0 // indirect 110 | golang.org/x/time v0.10.0 // indirect 111 | google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1 // indirect 112 | google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect 113 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250227231956-55c901821b1e // indirect 114 | google.golang.org/grpc v1.68.1 // indirect 115 | google.golang.org/protobuf v1.36.5 // indirect 116 | gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect 117 | gopkg.in/inf.v0 v0.9.1 // indirect 118 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect 119 | gopkg.in/yaml.v3 v3.0.1 // indirect 120 | k8s.io/klog/v2 v2.130.1 // indirect 121 | k8s.io/kube-openapi v0.0.0-20241212222426-2c72e554b1e7 // indirect 122 | k8s.io/utils v0.0.0-20241210054802-24370beab758 // indirect 123 | sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect 124 | sigs.k8s.io/structured-merge-diff/v4 v4.5.0 // indirect 125 | ) 126 | -------------------------------------------------------------------------------- /internal/cmd/catalog.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/operator-framework/kubectl-operator/pkg/action" 7 | ) 8 | 9 | func newCatalogCmd(cfg *action.Configuration) *cobra.Command { 10 | cmd := &cobra.Command{ 11 | Use: "catalog", 12 | Short: "Manage operator catalogs", 13 | } 14 | cmd.AddCommand( 15 | newCatalogAddCmd(cfg), 16 | newCatalogListCmd(cfg), 17 | newCatalogRemoveCmd(cfg), 18 | ) 19 | return cmd 20 | } 21 | -------------------------------------------------------------------------------- /internal/cmd/catalog_add.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "io" 5 | "time" 6 | 7 | "github.com/sirupsen/logrus" 8 | "github.com/spf13/cobra" 9 | "github.com/spf13/pflag" 10 | 11 | "github.com/operator-framework/operator-registry/pkg/image/containerdregistry" 12 | 13 | "github.com/operator-framework/kubectl-operator/internal/cmd/internal/log" 14 | internalaction "github.com/operator-framework/kubectl-operator/internal/pkg/action" 15 | "github.com/operator-framework/kubectl-operator/pkg/action" 16 | ) 17 | 18 | func newCatalogAddCmd(cfg *action.Configuration) *cobra.Command { 19 | a := internalaction.NewCatalogAdd(cfg) 20 | a.Logf = log.Printf 21 | 22 | cmd := &cobra.Command{ 23 | Use: "add ", 24 | Short: "Add an operator catalog", 25 | Args: cobra.ExactArgs(2), 26 | PreRun: func(cmd *cobra.Command, args []string) { 27 | regLogger := logrus.New() 28 | regLogger.SetOutput(io.Discard) 29 | a.RegistryOptions = []containerdregistry.RegistryOption{ 30 | containerdregistry.WithLog(logrus.NewEntry(regLogger)), 31 | } 32 | }, 33 | Run: func(cmd *cobra.Command, args []string) { 34 | a.CatalogSourceName = args[0] 35 | a.IndexImage = args[1] 36 | 37 | cs, err := a.Run(cmd.Context()) 38 | if err != nil { 39 | log.Fatalf("failed to add catalog: %v", err) 40 | } 41 | log.Printf("created catalogsource %q\n", cs.Name) 42 | }, 43 | } 44 | bindCatalogAddFlags(cmd.Flags(), a) 45 | 46 | return cmd 47 | } 48 | 49 | func bindCatalogAddFlags(fs *pflag.FlagSet, a *internalaction.CatalogAdd) { 50 | fs.StringVarP(&a.DisplayName, "display-name", "d", "", "display name of the index") 51 | fs.StringVarP(&a.Publisher, "publisher", "p", "", "publisher of the index") 52 | fs.DurationVar(&a.CleanupTimeout, "cleanup-timeout", time.Minute, "the amount of time to wait before cancelling cleanup") 53 | } 54 | -------------------------------------------------------------------------------- /internal/cmd/catalog_list.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "text/tabwriter" 7 | "time" 8 | 9 | "github.com/spf13/cobra" 10 | corev1 "k8s.io/api/core/v1" 11 | "k8s.io/apimachinery/pkg/util/duration" 12 | 13 | "github.com/operator-framework/kubectl-operator/internal/cmd/internal/log" 14 | internalaction "github.com/operator-framework/kubectl-operator/internal/pkg/action" 15 | "github.com/operator-framework/kubectl-operator/pkg/action" 16 | ) 17 | 18 | func newCatalogListCmd(cfg *action.Configuration) *cobra.Command { 19 | var allNamespaces bool 20 | l := internalaction.NewCatalogList(cfg) 21 | cmd := &cobra.Command{ 22 | Use: "list", 23 | Short: "List installed operator catalogs", 24 | Run: func(cmd *cobra.Command, args []string) { 25 | if allNamespaces { 26 | cfg.Namespace = corev1.NamespaceAll 27 | } 28 | catalogs, err := l.Run(cmd.Context()) 29 | if err != nil { 30 | log.Fatal(err) 31 | } 32 | 33 | if len(catalogs) == 0 { 34 | if cfg.Namespace == corev1.NamespaceAll { 35 | log.Print("No resources found") 36 | } else { 37 | log.Printf("No resources found in %s namespace.", cfg.Namespace) 38 | } 39 | return 40 | } 41 | 42 | nsCol := "" 43 | if allNamespaces { 44 | nsCol = "\tNAMESPACE" 45 | } 46 | tw := tabwriter.NewWriter(os.Stdout, 3, 4, 2, ' ', 0) 47 | _, _ = fmt.Fprintf(tw, "NAME%s\tDISPLAY\tTYPE\tPUBLISHER\tAGE\n", nsCol) 48 | for _, cs := range catalogs { 49 | ns := "" 50 | if allNamespaces { 51 | ns = "\t" + cs.Namespace 52 | } 53 | age := time.Since(cs.CreationTimestamp.Time) 54 | _, _ = fmt.Fprintf(tw, "%s%s\t%s\t%s\t%s\t%s\n", cs.Name, ns, cs.Spec.DisplayName, cs.Spec.SourceType, cs.Spec.Publisher, duration.HumanDuration(age)) 55 | } 56 | _ = tw.Flush() 57 | }, 58 | } 59 | cmd.Flags().BoolVarP(&allNamespaces, "all-namespaces", "A", false, "list catalogs in all namespaces") 60 | return cmd 61 | } 62 | -------------------------------------------------------------------------------- /internal/cmd/catalog_remove.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/operator-framework/kubectl-operator/internal/cmd/internal/log" 7 | internalaction "github.com/operator-framework/kubectl-operator/internal/pkg/action" 8 | "github.com/operator-framework/kubectl-operator/pkg/action" 9 | ) 10 | 11 | func newCatalogRemoveCmd(cfg *action.Configuration) *cobra.Command { 12 | u := internalaction.NewCatalogRemove(cfg) 13 | cmd := &cobra.Command{ 14 | Use: "remove ", 15 | Short: "Remove a operator catalog", 16 | Args: cobra.ExactArgs(1), 17 | Run: func(cmd *cobra.Command, args []string) { 18 | u.CatalogName = args[0] 19 | 20 | if err := u.Run(cmd.Context()); err != nil { 21 | log.Fatalf("failed to remove catalog %q: %v", u.CatalogName, err) 22 | } 23 | log.Printf("catalogsource %q removed", u.CatalogName) 24 | }, 25 | } 26 | 27 | return cmd 28 | } 29 | -------------------------------------------------------------------------------- /internal/cmd/internal/log/log.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | ) 8 | 9 | func Fatal(a ...interface{}) { 10 | Print(a...) 11 | os.Exit(1) 12 | } 13 | 14 | func Fatalf(f string, a ...interface{}) { 15 | Printf(f, a...) 16 | os.Exit(1) 17 | } 18 | 19 | func Print(a ...interface{}) { 20 | fmt.Println(a...) 21 | } 22 | 23 | func Printf(f string, a ...interface{}) { 24 | if !strings.HasSuffix(f, "\n") { 25 | f += "\n" 26 | } 27 | fmt.Printf(f, a...) 28 | } 29 | -------------------------------------------------------------------------------- /internal/cmd/internal/olmv1/action_suite_test.go: -------------------------------------------------------------------------------- 1 | package olmv1 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestCommand(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "Internal action Suite") 13 | } 14 | -------------------------------------------------------------------------------- /internal/cmd/internal/olmv1/catalog_create.go: -------------------------------------------------------------------------------- 1 | package olmv1 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/spf13/cobra" 7 | "github.com/spf13/pflag" 8 | 9 | "github.com/operator-framework/kubectl-operator/internal/cmd/internal/log" 10 | v1action "github.com/operator-framework/kubectl-operator/internal/pkg/v1/action" 11 | "github.com/operator-framework/kubectl-operator/pkg/action" 12 | ) 13 | 14 | // NewCatalogCreateCmd allows creating a new catalog 15 | func NewCatalogCreateCmd(cfg *action.Configuration) *cobra.Command { 16 | i := v1action.NewCatalogCreate(cfg) 17 | i.Logf = log.Printf 18 | 19 | cmd := &cobra.Command{ 20 | Use: "catalog ", 21 | Aliases: []string{"catalogs "}, 22 | Args: cobra.ExactArgs(2), 23 | Short: "Create a new catalog", 24 | Run: func(cmd *cobra.Command, args []string) { 25 | i.CatalogName = args[0] 26 | i.ImageSourceRef = args[1] 27 | 28 | if err := i.Run(cmd.Context()); err != nil { 29 | log.Fatalf("failed to create catalog %q: %v", i.CatalogName, err) 30 | } 31 | log.Printf("catalog %q created", i.CatalogName) 32 | }, 33 | } 34 | bindCatalogCreateFlags(cmd.Flags(), i) 35 | 36 | return cmd 37 | } 38 | 39 | func bindCatalogCreateFlags(fs *pflag.FlagSet, i *v1action.CatalogCreate) { 40 | fs.Int32Var(&i.Priority, "priority", 0, "priority determines the likelihood of a catalog being selected in conflict scenarios") 41 | fs.BoolVar(&i.Available, "available", true, "true means that the catalog should be active and serving data") 42 | fs.IntVar(&i.PollIntervalMinutes, "source-poll-interval-minutes", 10, "catalog source polling interval [in minutes]") 43 | fs.StringToStringVar(&i.Labels, "labels", map[string]string{}, "labels that will be added to the catalog") 44 | fs.DurationVar(&i.CleanupTimeout, "cleanup-timeout", time.Minute, "the amount of time to wait before cancelling cleanup after a failed creation attempt") 45 | } 46 | -------------------------------------------------------------------------------- /internal/cmd/internal/olmv1/catalog_delete.go: -------------------------------------------------------------------------------- 1 | package olmv1 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | "github.com/spf13/pflag" 6 | 7 | "github.com/operator-framework/kubectl-operator/internal/cmd/internal/log" 8 | v1action "github.com/operator-framework/kubectl-operator/internal/pkg/v1/action" 9 | "github.com/operator-framework/kubectl-operator/pkg/action" 10 | ) 11 | 12 | // NewCatalogDeleteCmd allows deleting either a single or all 13 | // existing catalogs 14 | func NewCatalogDeleteCmd(cfg *action.Configuration) *cobra.Command { 15 | d := v1action.NewCatalogDelete(cfg) 16 | d.Logf = log.Printf 17 | 18 | cmd := &cobra.Command{ 19 | Use: "catalog [catalog_name]", 20 | Aliases: []string{"catalogs [catalog_name]"}, 21 | Args: cobra.RangeArgs(0, 1), 22 | Short: "Delete either a single or all of the existing catalogs", 23 | Run: func(cmd *cobra.Command, args []string) { 24 | if len(args) == 0 { 25 | catalogs, err := d.Run(cmd.Context()) 26 | if err != nil { 27 | log.Fatalf("failed deleting catalogs: %v", err) 28 | } 29 | for _, catalog := range catalogs { 30 | log.Printf("catalog %q deleted", catalog) 31 | } 32 | 33 | return 34 | } 35 | 36 | d.CatalogName = args[0] 37 | if _, err := d.Run(cmd.Context()); err != nil { 38 | log.Fatalf("failed to delete catalog %q: %v", d.CatalogName, err) 39 | } 40 | log.Printf("catalog %q deleted", d.CatalogName) 41 | }, 42 | } 43 | bindCatalogDeleteFlags(cmd.Flags(), d) 44 | 45 | return cmd 46 | } 47 | 48 | func bindCatalogDeleteFlags(fs *pflag.FlagSet, d *v1action.CatalogDelete) { 49 | fs.BoolVar(&d.DeleteAll, "all", false, "delete all catalogs") 50 | } 51 | -------------------------------------------------------------------------------- /internal/cmd/internal/olmv1/catalog_installed_get.go: -------------------------------------------------------------------------------- 1 | package olmv1 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/operator-framework/kubectl-operator/internal/cmd/internal/log" 7 | v1action "github.com/operator-framework/kubectl-operator/internal/pkg/v1/action" 8 | "github.com/operator-framework/kubectl-operator/pkg/action" 9 | ) 10 | 11 | // NewCatalogInstalledGetCmd handles get commands in the form of: 12 | // catalog(s) [catalog_name] - this will either list all the installed operators 13 | // if no catalog_name has been provided or display the details of the specific 14 | // one otherwise 15 | func NewCatalogInstalledGetCmd(cfg *action.Configuration) *cobra.Command { 16 | i := v1action.NewCatalogInstalledGet(cfg) 17 | i.Logf = log.Printf 18 | 19 | cmd := &cobra.Command{ 20 | Use: "catalog [catalog_name]", 21 | Aliases: []string{"catalogs"}, 22 | Args: cobra.RangeArgs(0, 1), 23 | Short: "Display one or many installed catalogs", 24 | Run: func(cmd *cobra.Command, args []string) { 25 | if len(args) == 1 { 26 | i.CatalogName = args[0] 27 | } 28 | installedCatalogs, err := i.Run(cmd.Context()) 29 | if err != nil { 30 | log.Fatalf("failed getting installed catalog(s): %v", err) 31 | } 32 | 33 | printFormattedCatalogs(installedCatalogs...) 34 | }, 35 | } 36 | 37 | return cmd 38 | } 39 | -------------------------------------------------------------------------------- /internal/cmd/internal/olmv1/catalog_update.go: -------------------------------------------------------------------------------- 1 | package olmv1 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/operator-framework/kubectl-operator/internal/cmd/internal/log" 7 | v1action "github.com/operator-framework/kubectl-operator/internal/pkg/v1/action" 8 | "github.com/operator-framework/kubectl-operator/pkg/action" 9 | ) 10 | 11 | // NewCatalogUpdateCmd allows updating a selected clustercatalog 12 | func NewCatalogUpdateCmd(cfg *action.Configuration) *cobra.Command { 13 | i := v1action.NewCatalogUpdate(cfg) 14 | i.Logf = log.Printf 15 | 16 | var priority int32 17 | var pollInterval int 18 | var labels map[string]string 19 | 20 | cmd := &cobra.Command{ 21 | Use: "catalog ", 22 | Short: "Update a catalog", 23 | Args: cobra.ExactArgs(1), 24 | Run: func(cmd *cobra.Command, args []string) { 25 | i.CatalogName = args[0] 26 | if cmd.Flags().Changed("priority") { 27 | i.Priority = &priority 28 | } 29 | if cmd.Flags().Changed("source-poll-interval-minutes") { 30 | i.PollIntervalMinutes = &pollInterval 31 | } 32 | if cmd.Flags().Changed("labels") { 33 | i.Labels = labels 34 | } 35 | _, err := i.Run(cmd.Context()) 36 | if err != nil { 37 | log.Fatalf("failed to update catalog: %v", err) 38 | } 39 | log.Printf("catalog %q updated", i.CatalogName) 40 | }, 41 | } 42 | cmd.Flags().Int32Var(&priority, "priority", 0, "priority determines the likelihood of a catalog being selected in conflict scenarios") 43 | cmd.Flags().IntVar(&pollInterval, "source-poll-interval-minutes", 5, "catalog source polling interval [in minutes]. Set to 0 or -1 to remove the polling interval.") 44 | cmd.Flags().StringToStringVar(&labels, "labels", map[string]string{}, "labels that will be added to the catalog") 45 | cmd.Flags().StringVar(&i.AvailabilityMode, "availability-mode", "", "available means that the catalog should be active and serving data") 46 | cmd.Flags().StringVar(&i.ImageRef, "image", "", "Image reference for the catalog source. Leave unset to retain the current image.") 47 | cmd.Flags().BoolVar(&i.IgnoreUnset, "ignore-unset", true, "when enabled, any unset flag value will not be changed. Disabling means that for each unset value a default will be used instead") 48 | 49 | return cmd 50 | } 51 | -------------------------------------------------------------------------------- /internal/cmd/internal/olmv1/extension_delete.go: -------------------------------------------------------------------------------- 1 | package olmv1 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | "github.com/spf13/pflag" 6 | 7 | "github.com/operator-framework/kubectl-operator/internal/cmd/internal/log" 8 | v1action "github.com/operator-framework/kubectl-operator/internal/pkg/v1/action" 9 | "github.com/operator-framework/kubectl-operator/pkg/action" 10 | ) 11 | 12 | func NewExtensionDeleteCmd(cfg *action.Configuration) *cobra.Command { 13 | e := v1action.NewExtensionDelete(cfg) 14 | e.Logf = log.Printf 15 | 16 | cmd := &cobra.Command{ 17 | Use: "extension [extension_name]", 18 | Aliases: []string{"extensions [extension_name]"}, 19 | Short: "Delete an extension", 20 | Long: `Warning: Permanently deletes the named cluster extension object. 21 | If the extension contains CRDs, the CRDs will be deleted, which 22 | cascades to the deletion of all operands.`, 23 | Args: cobra.RangeArgs(0, 1), 24 | Run: func(cmd *cobra.Command, args []string) { 25 | if len(args) == 0 { 26 | extensions, err := e.Run(cmd.Context()) 27 | if err != nil { 28 | log.Fatalf("failed deleting extension: %v", err) 29 | } 30 | for _, extn := range extensions { 31 | log.Printf("extension %q deleted", extn) 32 | } 33 | 34 | return 35 | } 36 | e.ExtensionName = args[0] 37 | _, errs := e.Run(cmd.Context()) 38 | if errs != nil { 39 | log.Fatalf("delete extension: %v", errs) 40 | } 41 | log.Printf("deleted extension %q", e.ExtensionName) 42 | }, 43 | } 44 | bindExtensionDeleteFlags(cmd.Flags(), e) 45 | return cmd 46 | } 47 | 48 | func bindExtensionDeleteFlags(fs *pflag.FlagSet, e *v1action.ExtensionDeletion) { 49 | fs.BoolVarP(&e.DeleteAll, "all", "a", false, "delete all extensions") 50 | } 51 | -------------------------------------------------------------------------------- /internal/cmd/internal/olmv1/extension_install.go: -------------------------------------------------------------------------------- 1 | package olmv1 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/spf13/cobra" 7 | "github.com/spf13/pflag" 8 | 9 | "github.com/operator-framework/kubectl-operator/internal/cmd/internal/log" 10 | v1action "github.com/operator-framework/kubectl-operator/internal/pkg/v1/action" 11 | "github.com/operator-framework/kubectl-operator/pkg/action" 12 | ) 13 | 14 | func NewExtensionInstallCmd(cfg *action.Configuration) *cobra.Command { 15 | i := v1action.NewExtensionInstall(cfg) 16 | i.Logf = log.Printf 17 | 18 | cmd := &cobra.Command{ 19 | Use: "extension ", 20 | Short: "Install an extension", 21 | Args: cobra.ExactArgs(1), 22 | Run: func(cmd *cobra.Command, args []string) { 23 | i.ExtensionName = args[0] 24 | _, err := i.Run(cmd.Context()) 25 | if err != nil { 26 | log.Fatalf("failed to install extension: %v", err) 27 | } 28 | log.Printf("extension %q created", i.ExtensionName) 29 | }, 30 | } 31 | bindOperatorInstallFlags(cmd.Flags(), i) 32 | 33 | return cmd 34 | } 35 | 36 | func bindOperatorInstallFlags(fs *pflag.FlagSet, i *v1action.ExtensionInstall) { 37 | fs.StringVarP(&i.Namespace.Name, "namespace", "n", "", "namespace to install the operator in") 38 | fs.StringVarP(&i.PackageName, "package-name", "p", "", "package name of the operator to install") 39 | fs.StringSliceVarP(&i.Channels, "channels", "c", []string{}, "channels which would be to used for getting updates e.g --channels \"stable,dev-preview,preview\"") 40 | fs.StringVarP(&i.Version, "version", "v", "", "version (or version range) from which to resolve bundles") 41 | fs.StringVarP(&i.ServiceAccount, "service-account", "s", "default", "service account name to use for the extension installation") 42 | fs.DurationVarP(&i.CleanupTimeout, "cleanup-timeout", "d", time.Minute, "the amount of time to wait before cancelling cleanup after a failed creation attempt") 43 | } 44 | -------------------------------------------------------------------------------- /internal/cmd/internal/olmv1/extension_installed_get.go: -------------------------------------------------------------------------------- 1 | package olmv1 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/operator-framework/kubectl-operator/internal/cmd/internal/log" 7 | v1action "github.com/operator-framework/kubectl-operator/internal/pkg/v1/action" 8 | "github.com/operator-framework/kubectl-operator/pkg/action" 9 | ) 10 | 11 | // NewExtensionInstalledGetCmd handles get commands in the form of: 12 | // extension(s) [extension_name] - this will either list all the installed extensions 13 | // if no extension_name has been provided or display the details of the specific 14 | // one otherwise 15 | func NewExtensionInstalledGetCmd(cfg *action.Configuration) *cobra.Command { 16 | i := v1action.NewExtensionInstalledGet(cfg) 17 | i.Logf = log.Printf 18 | 19 | cmd := &cobra.Command{ 20 | Use: "extension [extension_name]", 21 | Aliases: []string{"extensions [extension_name]"}, 22 | Args: cobra.RangeArgs(0, 1), 23 | Short: "Display one or many installed extensions", 24 | Run: func(cmd *cobra.Command, args []string) { 25 | if len(args) == 1 { 26 | i.ExtensionName = args[0] 27 | } 28 | installedExtensions, err := i.Run(cmd.Context()) 29 | if err != nil { 30 | log.Fatalf("failed getting installed extension(s): %v", err) 31 | } 32 | 33 | printFormattedExtensions(installedExtensions...) 34 | }, 35 | } 36 | 37 | return cmd 38 | } 39 | -------------------------------------------------------------------------------- /internal/cmd/internal/olmv1/extension_update.go: -------------------------------------------------------------------------------- 1 | package olmv1 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | "github.com/spf13/pflag" 6 | 7 | "github.com/operator-framework/kubectl-operator/internal/cmd/internal/log" 8 | v1action "github.com/operator-framework/kubectl-operator/internal/pkg/v1/action" 9 | "github.com/operator-framework/kubectl-operator/pkg/action" 10 | ) 11 | 12 | // NewExtensionUpdateCmd allows updating a selected operator 13 | func NewExtensionUpdateCmd(cfg *action.Configuration) *cobra.Command { 14 | i := v1action.NewExtensionUpdate(cfg) 15 | i.Logf = log.Printf 16 | 17 | cmd := &cobra.Command{ 18 | Use: "extension ", 19 | Short: "Update an extension", 20 | Args: cobra.ExactArgs(1), 21 | Run: func(cmd *cobra.Command, args []string) { 22 | i.Package = args[0] 23 | _, err := i.Run(cmd.Context()) 24 | if err != nil { 25 | log.Fatalf("failed to update extension: %v", err) 26 | } 27 | log.Printf("extension %q updated", i.Package) 28 | }, 29 | } 30 | bindExtensionUpdateFlags(cmd.Flags(), i) 31 | 32 | return cmd 33 | } 34 | 35 | func bindExtensionUpdateFlags(fs *pflag.FlagSet, i *v1action.ExtensionUpdate) { 36 | fs.StringVar(&i.Version, "version", "", "desired extension version (single or range) in semVer format. AND operation with channels") 37 | fs.StringVar(&i.Selector, "selector", "", "filters the set of catalogs used in the bundle selection process. Empty means that all catalogs will be used in the bundle selection process") 38 | fs.StringArrayVar(&i.Channels, "channels", []string{}, "desired channels for extension versions. AND operation with version. Empty list means all available channels will be taken into consideration") 39 | fs.StringVar(&i.UpgradeConstraintPolicy, "upgrade-constraint-policy", "", "controls whether the upgrade path(s) defined in the catalog are enforced. One of CatalogProvided|SelfCertified), Default: CatalogProvided") 40 | fs.StringToStringVar(&i.Labels, "labels", map[string]string{}, "labels that will be set on the extension") 41 | fs.BoolVar(&i.IgnoreUnset, "ignore-unset", true, "when enabled, any unset flag value will not be changed. Disabling means that for each unset value a default will be used instead") 42 | } 43 | -------------------------------------------------------------------------------- /internal/cmd/internal/olmv1/printing.go: -------------------------------------------------------------------------------- 1 | package olmv1 2 | 3 | import ( 4 | "cmp" 5 | "fmt" 6 | "os" 7 | "slices" 8 | "text/tabwriter" 9 | "time" 10 | 11 | "github.com/blang/semver/v4" 12 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 13 | "k8s.io/apimachinery/pkg/util/duration" 14 | 15 | olmv1 "github.com/operator-framework/operator-controller/api/v1" 16 | ) 17 | 18 | func printFormattedExtensions(extensions ...olmv1.ClusterExtension) { 19 | tw := tabwriter.NewWriter(os.Stdout, 3, 4, 2, ' ', 0) 20 | _, _ = fmt.Fprint(tw, "NAME\tINSTALLED BUNDLE\tVERSION\tSOURCE TYPE\tINSTALLED\tPROGRESSING\tAGE\n") 21 | 22 | sortExtensions(extensions) 23 | for _, ext := range extensions { 24 | var bundleName, bundleVersion string 25 | if ext.Status.Install != nil { 26 | bundleName = ext.Status.Install.Bundle.Name 27 | bundleVersion = ext.Status.Install.Bundle.Version 28 | } 29 | age := time.Since(ext.CreationTimestamp.Time) 30 | _, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", 31 | ext.Name, 32 | bundleName, 33 | bundleVersion, 34 | ext.Spec.Source.SourceType, 35 | status(ext.Status.Conditions, olmv1.TypeInstalled), 36 | status(ext.Status.Conditions, olmv1.TypeProgressing), 37 | duration.HumanDuration(age), 38 | ) 39 | } 40 | _ = tw.Flush() 41 | } 42 | 43 | func printFormattedCatalogs(catalogs ...olmv1.ClusterCatalog) { 44 | tw := tabwriter.NewWriter(os.Stdout, 3, 4, 2, ' ', 0) 45 | _, _ = fmt.Fprint(tw, "NAME\tAVAILABILITY\tPRIORITY\tLASTUNPACKED\tSERVING\tAGE\n") 46 | 47 | sortCatalogs(catalogs) 48 | for _, cat := range catalogs { 49 | var lastUnpacked string 50 | if cat.Status.LastUnpacked != nil { 51 | duration.HumanDuration(time.Since(cat.Status.LastUnpacked.Time)) 52 | } 53 | age := time.Since(cat.CreationTimestamp.Time) 54 | _, _ = fmt.Fprintf(tw, "%s\t%s\t%d\t%s\t%s\t%s\n", 55 | cat.Name, 56 | string(cat.Spec.AvailabilityMode), 57 | cat.Spec.Priority, 58 | lastUnpacked, 59 | status(cat.Status.Conditions, olmv1.TypeServing), 60 | duration.HumanDuration(age), 61 | ) 62 | } 63 | _ = tw.Flush() 64 | } 65 | 66 | // sortExtensions sorts extensions in place and uses the following sorting order: 67 | // name (asc), version (desc) 68 | func sortExtensions(extensions []olmv1.ClusterExtension) { 69 | slices.SortFunc(extensions, func(a, b olmv1.ClusterExtension) int { 70 | if a.Status.Install == nil || b.Status.Install == nil { 71 | return cmp.Compare(a.Name, b.Name) 72 | } 73 | return cmp.Or( 74 | cmp.Compare(a.Name, b.Name), 75 | -semver.MustParse(a.Status.Install.Bundle.Version).Compare(semver.MustParse(b.Status.Install.Bundle.Version)), 76 | ) 77 | }) 78 | } 79 | 80 | // sortCatalogs sorts catalogs in place and uses the following sorting order: 81 | // availability (asc), priority (desc), name (asc) 82 | func sortCatalogs(catalogs []olmv1.ClusterCatalog) { 83 | slices.SortFunc(catalogs, func(a, b olmv1.ClusterCatalog) int { 84 | return cmp.Or( 85 | cmp.Compare(a.Spec.AvailabilityMode, b.Spec.AvailabilityMode), 86 | -cmp.Compare(a.Spec.Priority, b.Spec.Priority), 87 | cmp.Compare(a.Name, b.Name), 88 | ) 89 | }) 90 | } 91 | 92 | func status(conditions []metav1.Condition, typ string) string { 93 | for _, condition := range conditions { 94 | if condition.Type == typ { 95 | return string(condition.Status) 96 | } 97 | } 98 | 99 | return "Unknown" 100 | } 101 | -------------------------------------------------------------------------------- /internal/cmd/internal/olmv1/printing_test.go: -------------------------------------------------------------------------------- 1 | package olmv1 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo" 5 | . "github.com/onsi/gomega" 6 | 7 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 8 | 9 | olmv1 "github.com/operator-framework/operator-controller/api/v1" 10 | ) 11 | 12 | var _ = Describe("SortCatalogs", func() { 13 | It("sorts catalogs in correct order", func() { 14 | catalogs := []olmv1.ClusterCatalog{ 15 | newClusterCatalog("cat-unavailable-0", olmv1.AvailabilityModeUnavailable, 0), 16 | newClusterCatalog("cat-unavailable-1", olmv1.AvailabilityModeUnavailable, 1), 17 | newClusterCatalog("cat-available-0", olmv1.AvailabilityModeAvailable, 0), 18 | newClusterCatalog("cat-available-1", olmv1.AvailabilityModeAvailable, 1), 19 | } 20 | sortCatalogs(catalogs) 21 | 22 | Expect(catalogs[0].Name).To(Equal("cat-available-1")) 23 | Expect(catalogs[1].Name).To(Equal("cat-available-0")) 24 | Expect(catalogs[2].Name).To(Equal("cat-unavailable-1")) 25 | Expect(catalogs[3].Name).To(Equal("cat-unavailable-0")) 26 | }) 27 | }) 28 | 29 | var _ = Describe("SortExtensions", func() { 30 | It("sorts extensions in correct order", func() { 31 | extensions := []olmv1.ClusterExtension{ 32 | newClusterExtension("op-1", "1.0.0"), 33 | newClusterExtension("op-1", "1.0.1"), 34 | newClusterExtension("op-1", "1.0.1-rc4"), 35 | newClusterExtension("op-1", "1.0.1-rc2"), 36 | newClusterExtension("op-2", "2.0.0"), 37 | } 38 | sortExtensions(extensions) 39 | 40 | Expect(extensions[0].Status.Install.Bundle.Version).To(Equal("1.0.1")) 41 | Expect(extensions[1].Status.Install.Bundle.Version).To(Equal("1.0.1-rc4")) 42 | Expect(extensions[2].Status.Install.Bundle.Version).To(Equal("1.0.1-rc2")) 43 | Expect(extensions[3].Status.Install.Bundle.Version).To(Equal("1.0.0")) 44 | Expect(extensions[4].Status.Install.Bundle.Version).To(Equal("2.0.0")) 45 | }) 46 | }) 47 | 48 | func newClusterCatalog(name string, availabilityMode olmv1.AvailabilityMode, priority int32) olmv1.ClusterCatalog { 49 | return olmv1.ClusterCatalog{ 50 | ObjectMeta: metav1.ObjectMeta{Name: name}, 51 | Spec: olmv1.ClusterCatalogSpec{AvailabilityMode: availabilityMode, Priority: priority}, 52 | } 53 | } 54 | 55 | func newClusterExtension(name, version string) olmv1.ClusterExtension { 56 | return olmv1.ClusterExtension{ 57 | ObjectMeta: metav1.ObjectMeta{Name: name}, 58 | Status: olmv1.ClusterExtensionStatus{ 59 | Install: &olmv1.ClusterExtensionInstallStatus{ 60 | Bundle: olmv1.BundleMetadata{ 61 | Name: name, 62 | Version: version, 63 | }, 64 | }, 65 | }, 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /internal/cmd/olmv1.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | 6 | "github.com/operator-framework/kubectl-operator/internal/cmd/internal/olmv1" 7 | "github.com/operator-framework/kubectl-operator/pkg/action" 8 | ) 9 | 10 | func newOlmV1Cmd(cfg *action.Configuration) *cobra.Command { 11 | cmd := &cobra.Command{ 12 | Use: "olmv1", 13 | Short: "Manage extensions via OLMv1 in a cluster from the command line", 14 | Long: "Manage extensions via OLMv1 in a cluster from the command line.", 15 | } 16 | 17 | getCmd := &cobra.Command{ 18 | Use: "get", 19 | Short: "Display one or many resource(s)", 20 | Long: "Display one or many resource(s)", 21 | } 22 | getCmd.AddCommand( 23 | olmv1.NewExtensionInstalledGetCmd(cfg), 24 | olmv1.NewCatalogInstalledGetCmd(cfg), 25 | ) 26 | 27 | createCmd := &cobra.Command{ 28 | Use: "create", 29 | Short: "Create a resource", 30 | Long: "Create a resource", 31 | } 32 | createCmd.AddCommand(olmv1.NewCatalogCreateCmd(cfg)) 33 | 34 | deleteCmd := &cobra.Command{ 35 | Use: "delete", 36 | Short: "Delete a resource", 37 | Long: "Delete a resource", 38 | } 39 | deleteCmd.AddCommand( 40 | olmv1.NewCatalogDeleteCmd(cfg), 41 | olmv1.NewExtensionDeleteCmd(cfg), 42 | ) 43 | 44 | updateCmd := &cobra.Command{ 45 | Use: "update", 46 | Short: "Update a resource", 47 | Long: "Update a resource", 48 | } 49 | updateCmd.AddCommand( 50 | olmv1.NewExtensionUpdateCmd(cfg), 51 | olmv1.NewCatalogUpdateCmd(cfg), 52 | ) 53 | 54 | installCmd := &cobra.Command{ 55 | Use: "install", 56 | Short: "Install a resource", 57 | Long: "Install a resource", 58 | } 59 | installCmd.AddCommand(olmv1.NewExtensionInstallCmd(cfg)) 60 | 61 | cmd.AddCommand( 62 | installCmd, 63 | getCmd, 64 | createCmd, 65 | deleteCmd, 66 | updateCmd, 67 | ) 68 | 69 | return cmd 70 | } 71 | -------------------------------------------------------------------------------- /internal/cmd/operator_describe.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/spf13/cobra" 8 | "k8s.io/apimachinery/pkg/util/sets" 9 | 10 | "github.com/operator-framework/kubectl-operator/internal/cmd/internal/log" 11 | internalaction "github.com/operator-framework/kubectl-operator/internal/pkg/action" 12 | "github.com/operator-framework/kubectl-operator/internal/pkg/operator" 13 | "github.com/operator-framework/kubectl-operator/pkg/action" 14 | ) 15 | 16 | var ( 17 | // output helpers for the describe subcommand 18 | pkgHdr = asHeader("Package") 19 | repoHdr = asHeader("Repository") 20 | catHdr = asHeader("Catalog") 21 | chHdr = asHeader("Channels") 22 | imHdr = asHeader("Install Modes") 23 | sdHdr = asHeader("Description") 24 | ldHdr = asHeader("Long Description") 25 | 26 | repoAnnot = "repository" 27 | descAnnot = "description" 28 | ) 29 | 30 | func newOperatorDescribeCmd(cfg *action.Configuration) *cobra.Command { 31 | l := internalaction.NewOperatorListAvailable(cfg) 32 | // receivers for cmdline flags 33 | var channel string 34 | var longDescription bool 35 | 36 | cmd := &cobra.Command{ 37 | Use: "describe ", 38 | Short: "Describe an operator", 39 | Args: cobra.ExactArgs(1), 40 | Run: func(cmd *cobra.Command, args []string) { 41 | // the operator to show details about, provided by the user 42 | l.Package = args[0] 43 | 44 | // Find the package manifest and package channel for the operator 45 | pms, err := l.Run(cmd.Context()) 46 | if err != nil { 47 | log.Fatal(err) 48 | } 49 | 50 | // we only expect one item because describe always searches 51 | // for a specific operator by name 52 | pm := pms[0] 53 | 54 | pc, err := pm.GetChannel(channel) 55 | if err != nil { 56 | // the requested channel doesn't exist 57 | log.Fatal(err) 58 | } 59 | 60 | // prepare what we want to print to the console 61 | out := make([]string, 0) 62 | 63 | // Starting adding data to our output. 64 | out = append(out, 65 | // package 66 | pkgHdr+fmt.Sprintf("%s %s (by %s)\n\n", 67 | pc.CurrentCSVDesc.DisplayName, 68 | pc.CurrentCSVDesc.Version, 69 | pc.CurrentCSVDesc.Provider.Name), 70 | // repo 71 | repoHdr+fmt.Sprintf("%s\n\n", 72 | pc.CurrentCSVDesc.Annotations[repoAnnot]), 73 | // catalog 74 | catHdr+fmt.Sprintf("%s\n\n", pm.Status.CatalogSourceDisplayName), 75 | // available channels 76 | chHdr+fmt.Sprintf("%s\n\n", 77 | strings.Join(getAvailableChannelsWithMarkers(*pc, pm), "\n")), 78 | // install modes 79 | imHdr+fmt.Sprintf("%s\n\n", 80 | strings.Join(sets.List[string](pc.GetSupportedInstallModes()), "\n")), 81 | // description 82 | sdHdr+fmt.Sprintf("%s\n", 83 | pc.CurrentCSVDesc.Annotations[descAnnot]), 84 | ) 85 | 86 | // if the user requested a long description, add it to the output as well 87 | if longDescription { 88 | out = append(out, 89 | "\n"+ldHdr+pm.Status.Channels[0].CurrentCSVDesc.LongDescription) 90 | } 91 | 92 | // finally, print operator information to the console 93 | for _, v := range out { 94 | fmt.Print(v) 95 | } 96 | }, 97 | } 98 | 99 | // add flags to the flagset for this command. 100 | bindOperatorListAvailableFlags(cmd.Flags(), l) 101 | cmd.Flags().StringVarP(&channel, "channel", "C", "", "package channel to describe") 102 | cmd.Flags().BoolVarP(&longDescription, "with-long-description", "L", false, "include long description") 103 | 104 | return cmd 105 | } 106 | 107 | // asHeader returns the string with "header bars" for displaying in 108 | // plain text cases. 109 | func asHeader(s string) string { 110 | return fmt.Sprintf("== %s ==\n", s) 111 | } 112 | 113 | // getAvailableChannelsWithMarkers parses all available package channels for a package manifest 114 | // and returns those channel names with indicators for pretty-printing whether they are shown 115 | // or the default channel 116 | func getAvailableChannelsWithMarkers(channel operator.PackageChannel, pm operator.PackageManifest) []string { 117 | channels := make([]string, len(pm.Status.Channels)) 118 | for i, ch := range pm.Status.Channels { 119 | n := ch.Name 120 | if ch.IsDefaultChannel(pm.PackageManifest) { 121 | n += " (default)" 122 | } 123 | if channel.Name == ch.Name { 124 | n += " (shown)" 125 | } 126 | channels[i] = n 127 | } 128 | 129 | return channels 130 | } 131 | -------------------------------------------------------------------------------- /internal/cmd/operator_install.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/spf13/cobra" 8 | "github.com/spf13/pflag" 9 | 10 | "github.com/operator-framework/api/pkg/operators/v1alpha1" 11 | 12 | "github.com/operator-framework/kubectl-operator/internal/cmd/internal/log" 13 | internalaction "github.com/operator-framework/kubectl-operator/internal/pkg/action" 14 | "github.com/operator-framework/kubectl-operator/pkg/action" 15 | ) 16 | 17 | func newOperatorInstallCmd(cfg *action.Configuration) *cobra.Command { 18 | i := internalaction.NewOperatorInstall(cfg) 19 | i.Logf = log.Printf 20 | 21 | cmd := &cobra.Command{ 22 | Use: "install ", 23 | Short: "Install an operator", 24 | Args: cobra.ExactArgs(1), 25 | Run: func(cmd *cobra.Command, args []string) { 26 | i.Package = args[0] 27 | csv, err := i.Run(cmd.Context()) 28 | if err != nil { 29 | log.Fatalf("failed to install operator: %v", err) 30 | } 31 | log.Printf("operator %q installed; installed csv is %q", i.Package, csv.Name) 32 | }, 33 | } 34 | bindOperatorInstallFlags(cmd.Flags(), i) 35 | 36 | return cmd 37 | } 38 | 39 | func bindOperatorInstallFlags(fs *pflag.FlagSet, i *internalaction.OperatorInstall) { 40 | fs.StringVarP(&i.Channel, "channel", "c", "", "subscription channel") 41 | fs.VarP(&i.Approval, "approval", "a", fmt.Sprintf("approval (%s or %s)", v1alpha1.ApprovalManual, v1alpha1.ApprovalAutomatic)) 42 | fs.StringVarP(&i.Version, "version", "v", "", "install specific version for operator (default latest)") 43 | fs.StringSliceVarP(&i.WatchNamespaces, "watch", "w", []string{}, "namespaces to watch") 44 | fs.DurationVar(&i.CleanupTimeout, "cleanup-timeout", time.Minute, "the amount of time to wait before cancelling cleanup") 45 | fs.BoolVarP(&i.CreateOperatorGroup, "create-operator-group", "C", false, "create operator group if necessary") 46 | } 47 | -------------------------------------------------------------------------------- /internal/cmd/operator_list.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "sort" 7 | "strings" 8 | "text/tabwriter" 9 | "time" 10 | 11 | "github.com/spf13/cobra" 12 | corev1 "k8s.io/api/core/v1" 13 | "k8s.io/apimachinery/pkg/util/duration" 14 | 15 | "github.com/operator-framework/kubectl-operator/internal/cmd/internal/log" 16 | internalaction "github.com/operator-framework/kubectl-operator/internal/pkg/action" 17 | "github.com/operator-framework/kubectl-operator/pkg/action" 18 | ) 19 | 20 | func newOperatorListCmd(cfg *action.Configuration) *cobra.Command { 21 | var allNamespaces bool 22 | l := internalaction.NewOperatorList(cfg) 23 | cmd := &cobra.Command{ 24 | Use: "list", 25 | Short: "List installed operators", 26 | Args: cobra.ExactArgs(0), 27 | Run: func(cmd *cobra.Command, args []string) { 28 | if allNamespaces { 29 | cfg.Namespace = corev1.NamespaceAll 30 | } 31 | subs, err := l.Run(cmd.Context()) 32 | if err != nil { 33 | log.Fatalf("list operators: %v", err) 34 | } 35 | 36 | if len(subs) == 0 { 37 | if cfg.Namespace == corev1.NamespaceAll { 38 | log.Print("No resources found") 39 | } else { 40 | log.Printf("No resources found in %s namespace.", cfg.Namespace) 41 | } 42 | return 43 | } 44 | 45 | sort.SliceStable(subs, func(i, j int) bool { 46 | return strings.Compare(subs[i].Spec.Package, subs[j].Spec.Package) < 0 47 | }) 48 | nsCol := "" 49 | if allNamespaces { 50 | nsCol = "\tNAMESPACE" 51 | } 52 | tw := tabwriter.NewWriter(os.Stdout, 3, 4, 2, ' ', 0) 53 | _, _ = fmt.Fprintf(tw, "PACKAGE%s\tSUBSCRIPTION\tINSTALLED CSV\tCURRENT CSV\tSTATUS\tAGE\n", nsCol) 54 | for _, sub := range subs { 55 | ns := "" 56 | if allNamespaces { 57 | ns = "\t" + sub.Namespace 58 | } 59 | age := time.Since(sub.CreationTimestamp.Time) 60 | _, _ = fmt.Fprintf(tw, "%s%s\t%s\t%s\t%s\t%s\t%s\n", sub.Spec.Package, ns, sub.Name, sub.Status.InstalledCSV, sub.Status.CurrentCSV, sub.Status.State, duration.HumanDuration(age)) 61 | } 62 | _ = tw.Flush() 63 | }, 64 | } 65 | cmd.Flags().BoolVarP(&allNamespaces, "all-namespaces", "A", false, "list operators in all namespaces") 66 | return cmd 67 | } 68 | -------------------------------------------------------------------------------- /internal/cmd/operator_list_available.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "sort" 7 | "strings" 8 | "text/tabwriter" 9 | "time" 10 | 11 | "github.com/spf13/cobra" 12 | "github.com/spf13/pflag" 13 | corev1 "k8s.io/api/core/v1" 14 | "k8s.io/apimachinery/pkg/util/duration" 15 | 16 | "github.com/operator-framework/kubectl-operator/internal/cmd/internal/log" 17 | internalaction "github.com/operator-framework/kubectl-operator/internal/pkg/action" 18 | "github.com/operator-framework/kubectl-operator/pkg/action" 19 | ) 20 | 21 | func newOperatorListAvailableCmd(cfg *action.Configuration) *cobra.Command { 22 | l := internalaction.NewOperatorListAvailable(cfg) 23 | cmd := &cobra.Command{ 24 | Use: "list-available", 25 | Short: "List operators available to be installed", 26 | Args: cobra.MaximumNArgs(1), 27 | Run: func(cmd *cobra.Command, args []string) { 28 | if len(args) == 1 { 29 | l.Package = args[0] 30 | } 31 | 32 | operators, err := l.Run(cmd.Context()) 33 | if err != nil { 34 | log.Fatal(err) 35 | } 36 | 37 | if len(operators) == 0 { 38 | if cfg.Namespace == corev1.NamespaceAll { 39 | log.Print("No resources found") 40 | } else { 41 | log.Printf("No resources found in %s namespace.\n", cfg.Namespace) 42 | } 43 | return 44 | } 45 | 46 | sort.SliceStable(operators, func(i, j int) bool { 47 | return strings.Compare(operators[i].Name, operators[j].Name) < 0 48 | }) 49 | 50 | tw := tabwriter.NewWriter(os.Stdout, 3, 4, 2, ' ', 0) 51 | _, _ = fmt.Fprintf(tw, "NAME\tCATALOG\tCHANNEL\tLATEST CSV\tAGE\n") 52 | for _, op := range operators { 53 | age := time.Since(op.CreationTimestamp.Time) 54 | for _, ch := range op.Status.Channels { 55 | _, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", op.Name, op.Status.CatalogSourceDisplayName, ch.Name, ch.CurrentCSV, duration.HumanDuration(age)) 56 | } 57 | } 58 | _ = tw.Flush() 59 | }, 60 | } 61 | bindOperatorListAvailableFlags(cmd.Flags(), l) 62 | return cmd 63 | } 64 | 65 | func bindOperatorListAvailableFlags(fs *pflag.FlagSet, l *internalaction.OperatorListAvailable) { 66 | fs.VarP(&l.Catalog, "catalog", "c", "catalog to query (default: search all cluster catalogs)") 67 | } 68 | -------------------------------------------------------------------------------- /internal/cmd/operator_list_operands.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "os" 9 | "strings" 10 | "text/tabwriter" 11 | "time" 12 | 13 | "github.com/spf13/cobra" 14 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 15 | "k8s.io/apimachinery/pkg/util/duration" 16 | "sigs.k8s.io/yaml" 17 | 18 | "github.com/operator-framework/kubectl-operator/internal/cmd/internal/log" 19 | "github.com/operator-framework/kubectl-operator/pkg/action" 20 | ) 21 | 22 | func newOperatorListOperandsCmd(cfg *action.Configuration) *cobra.Command { 23 | l := action.NewOperatorListOperands(cfg) 24 | output := "" 25 | validOutputs := []string{"json", "yaml"} 26 | 27 | cmd := &cobra.Command{ 28 | Use: "list-operands ", 29 | Short: "List operands of an installed operator", 30 | Long: `List operands of an installed operator. 31 | 32 | This command lists all operands found throughout the cluster for the operator 33 | specified on the command line. Since the scope of an operator is restricted by 34 | its operator group, the output will include namespace-scoped operands from the 35 | operator group's target namespaces and all cluster-scoped operands. 36 | 37 | To search for operands for an operator in a different namespace, use the 38 | --namespace flag. By default, the namespace from the current context is used. 39 | 40 | Operand kinds are determined from the owned CustomResourceDefinitions listed in 41 | the operator's ClusterServiceVersion.`, 42 | Args: cobra.ExactArgs(1), 43 | Run: func(cmd *cobra.Command, args []string) { 44 | writeOutput := func(io.Writer, *unstructured.UnstructuredList) error { panic("writeOutput was not set") } //nolint:staticcheck 45 | switch output { 46 | case "json": 47 | writeOutput = writeJSON 48 | case "yaml": 49 | writeOutput = writeYAML 50 | case "": 51 | writeOutput = writeTable 52 | default: 53 | log.Fatalf("invalid value for flag output %q, expected one of %s", output, strings.Join(validOutputs, "|")) 54 | } 55 | 56 | operands, err := l.Run(cmd.Context(), args[0]) 57 | if err != nil { 58 | log.Fatalf("list operands: %v", err) 59 | } 60 | 61 | if len(operands.Items) == 0 { 62 | log.Print("No resources found") 63 | return 64 | } 65 | 66 | if err := writeOutput(os.Stdout, operands); err != nil { 67 | log.Fatal(err) 68 | } 69 | }, 70 | } 71 | cmd.Flags().StringVarP(&output, "output", "o", output, fmt.Sprintf("Output format. One of: %s", strings.Join(validOutputs, "|"))) 72 | return cmd 73 | } 74 | 75 | func writeTable(w io.Writer, operands *unstructured.UnstructuredList) error { 76 | var buf bytes.Buffer 77 | tw := tabwriter.NewWriter(&buf, 3, 4, 2, ' ', 0) 78 | if _, err := fmt.Fprintf(tw, "APIVERSION\tKIND\tNAMESPACE\tNAME\tAGE\n"); err != nil { 79 | return err 80 | } 81 | for _, o := range operands.Items { 82 | age := time.Since(o.GetCreationTimestamp().Time) 83 | if _, err := fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", o.GetAPIVersion(), o.GetKind(), o.GetNamespace(), o.GetName(), duration.HumanDuration(age)); err != nil { 84 | return err 85 | } 86 | } 87 | if err := tw.Flush(); err != nil { 88 | return err 89 | } 90 | if _, err := w.Write(buf.Bytes()); err != nil { 91 | return err 92 | } 93 | return nil 94 | } 95 | 96 | func writeJSON(w io.Writer, operands *unstructured.UnstructuredList) error { 97 | out, err := json.Marshal(operands) 98 | if err != nil { 99 | return err 100 | } 101 | 102 | var buf bytes.Buffer 103 | if err := json.Indent(&buf, out, "", " "); err != nil { 104 | return err 105 | } 106 | if _, err := w.Write(buf.Bytes()); err != nil { 107 | return err 108 | } 109 | return nil 110 | } 111 | 112 | func writeYAML(w io.Writer, operands *unstructured.UnstructuredList) error { 113 | var jsonWriter bytes.Buffer 114 | if err := writeJSON(&jsonWriter, operands); err != nil { 115 | return err 116 | } 117 | out, err := yaml.JSONToYAML(jsonWriter.Bytes()) 118 | if err != nil { 119 | return err 120 | } 121 | if _, err := w.Write(out); err != nil { 122 | return err 123 | } 124 | return nil 125 | } 126 | -------------------------------------------------------------------------------- /internal/cmd/operator_uninstall.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/spf13/cobra" 7 | "github.com/spf13/pflag" 8 | 9 | "github.com/operator-framework/kubectl-operator/internal/cmd/internal/log" 10 | internalaction "github.com/operator-framework/kubectl-operator/internal/pkg/action" 11 | "github.com/operator-framework/kubectl-operator/internal/pkg/operand" 12 | "github.com/operator-framework/kubectl-operator/pkg/action" 13 | ) 14 | 15 | func newOperatorUninstallCmd(cfg *action.Configuration) *cobra.Command { 16 | u := internalaction.NewOperatorUninstall(cfg) 17 | u.Logf = log.Printf 18 | 19 | cmd := &cobra.Command{ 20 | Use: "uninstall ", 21 | Short: "Uninstall an operator and operands", 22 | Long: `Uninstall removes the subscription, operator and optionally operands managed 23 | by the operator as well as the relevant operatorgroup. 24 | 25 | Warning: this command permanently deletes objects from the cluster. Running 26 | uninstall concurrently with other operations could result in undefined behavior. 27 | 28 | The uninstall command first checks to find the subscription associated with the 29 | operator. It then lists all operands found throughout the cluster for the 30 | operator specified if one is found. Since the scope of an operator is restricted 31 | by its operator group, this search will include namespace-scoped operands from 32 | the operator group's target namespaces and all cluster-scoped operands. 33 | 34 | The operand-deletion strategy is then considered if any operands are found 35 | on-cluster. One of abort|ignore|delete. By default, the strategy is "abort", 36 | which means that if any operands are found when deleting the operator abort the 37 | uninstall without deleting anything. The "ignore" strategy keeps the operands on 38 | cluster and deletes the subscription and the operator. The "delete" strategy 39 | deletes the subscription, operands, and after they have finished finalizing, the 40 | operator itself. 41 | 42 | Setting --delete-operator-groups to true will delete the operatorgroup in the 43 | provided namespace if no other active subscriptions are currently in that 44 | namespace, after removing the operator. The subscription and operatorgroup will 45 | be removed even if the operator is not found. 46 | 47 | There are other deletion flags for removing additional objects, such as custom 48 | resource definitions, operator objects, and any other objects deployed alongside 49 | the operator (e.g. RBAC objects for the operator). These flags are: 50 | 51 | --delete-operator 52 | 53 | Deletes all objects associated with the operator by looking up the 54 | operator object for the operator and deleting every referenced object 55 | and then deleting the operator object itself. This implies the flag 56 | '--operand-strategy=delete' because it is impossible to delete CRDs 57 | without also deleting instances of those CRDs. 58 | 59 | -X, --delete-all 60 | 61 | This is a convenience flag that is effectively equivalent to the flags 62 | '--delete-operator=true --delete-operator-groups=true'. 63 | 64 | NOTE: This command does not recursively uninstall unused dependencies. To return 65 | a cluster to its state prior to a 'kubectl operator install' call, each 66 | dependency of the operator that was installed automatically by OLM must be 67 | individually uninstalled. 68 | `, 69 | Args: cobra.ExactArgs(1), 70 | Run: func(cmd *cobra.Command, args []string) { 71 | u.Package = args[0] 72 | if err := u.Run(cmd.Context()); err != nil { 73 | if errors.Is(err, operand.ErrAbortStrategy) { 74 | log.Fatalf("uninstall operator: %v"+"\n\n%s", err, 75 | "See kubectl operator uninstall --help for more information on operand deletion strategies.") 76 | } 77 | log.Fatalf("uninstall operator: %v", err) 78 | } 79 | }, 80 | } 81 | bindOperatorUninstallFlags(cmd.Flags(), u) 82 | return cmd 83 | } 84 | 85 | func bindOperatorUninstallFlags(fs *pflag.FlagSet, u *internalaction.OperatorUninstall) { 86 | fs.BoolVarP(&u.DeleteAll, "delete-all", "X", false, "delete all objects associated with the operator, implies --delete-operator, --operand-strategy=delete, --delete-operator-groups") 87 | fs.BoolVar(&u.DeleteOperator, "delete-operator", false, "delete operator object associated with the operator, --operand-strategy=delete") 88 | fs.BoolVar(&u.DeleteOperatorGroups, "delete-operator-groups", false, "delete operator groups if no other operators remain") 89 | fs.StringSliceVar(&u.DeleteOperatorGroupNames, "delete-operator-group-names", nil, "specific operator group names to delete (only effective with --delete-operator-groups)") 90 | fs.VarP(&u.OperandStrategy, "operand-strategy", "s", "determines how to handle operands when deleting the operator, one of abort|ignore|delete (default: abort)") 91 | } 92 | -------------------------------------------------------------------------------- /internal/cmd/operator_upgrade.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | "github.com/spf13/pflag" 6 | 7 | "github.com/operator-framework/kubectl-operator/internal/cmd/internal/log" 8 | internalaction "github.com/operator-framework/kubectl-operator/internal/pkg/action" 9 | "github.com/operator-framework/kubectl-operator/pkg/action" 10 | ) 11 | 12 | func newOperatorUpgradeCmd(cfg *action.Configuration) *cobra.Command { 13 | u := internalaction.NewOperatorUpgrade(cfg) 14 | cmd := &cobra.Command{ 15 | Use: "upgrade ", 16 | Short: "Upgrade an operator", 17 | Args: cobra.ExactArgs(1), 18 | Run: func(cmd *cobra.Command, args []string) { 19 | u.Package = args[0] 20 | csv, err := u.Run(cmd.Context()) 21 | if err != nil { 22 | log.Fatalf("failed to upgrade operator: %v", err) 23 | } 24 | log.Printf("operator %q upgraded; installed csv is %q", u.Package, csv.Name) 25 | }, 26 | } 27 | bindOperatorUpgradeFlags(cmd.Flags(), u) 28 | return cmd 29 | } 30 | 31 | func bindOperatorUpgradeFlags(fs *pflag.FlagSet, u *internalaction.OperatorUpgrade) { 32 | fs.StringVarP(&u.Channel, "channel", "c", "", "subscription channel") 33 | } 34 | -------------------------------------------------------------------------------- /internal/cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/spf13/cobra" 8 | 9 | "github.com/operator-framework/kubectl-operator/internal/cmd/internal/log" 10 | "github.com/operator-framework/kubectl-operator/pkg/action" 11 | ) 12 | 13 | func Execute() { 14 | if err := newCmd().Execute(); err != nil { 15 | log.Fatal(err) 16 | } 17 | } 18 | func newCmd() *cobra.Command { 19 | cmd := &cobra.Command{ 20 | Use: "operator", 21 | Short: "Manage operators in a cluster from the command line", 22 | Long: `Manage operators in a cluster from the command line. 23 | 24 | kubectl operator helps you manage operator installations in your 25 | cluster. It can install and uninstall operator catalogs, list 26 | operators available for installation, and install and uninstall 27 | operators from the installed catalogs.`, 28 | } 29 | 30 | var ( 31 | cfg action.Configuration 32 | timeout time.Duration 33 | cancel context.CancelFunc 34 | ) 35 | 36 | flags := cmd.PersistentFlags() 37 | cfg.BindFlags(flags) 38 | flags.DurationVar(&timeout, "timeout", 1*time.Minute, "The amount of time to wait before giving up on an operation.") 39 | 40 | cmd.PersistentPreRunE = func(cmd *cobra.Command, _ []string) error { 41 | var ctx context.Context 42 | ctx, cancel = context.WithTimeout(cmd.Context(), timeout) 43 | 44 | cmd.SetContext(ctx) 45 | 46 | return cfg.Load() 47 | } 48 | cmd.PersistentPostRun = func(command *cobra.Command, _ []string) { 49 | cancel() 50 | } 51 | 52 | cmd.AddCommand( 53 | newCatalogCmd(&cfg), 54 | newOperatorInstallCmd(&cfg), 55 | newOperatorUpgradeCmd(&cfg), 56 | newOperatorUninstallCmd(&cfg), 57 | newOperatorListCmd(&cfg), 58 | newOperatorListAvailableCmd(&cfg), 59 | newOperatorListOperandsCmd(&cfg), 60 | newOperatorDescribeCmd(&cfg), 61 | newOlmV1Cmd(&cfg), 62 | newVersionCmd(), 63 | ) 64 | 65 | return cmd 66 | } 67 | -------------------------------------------------------------------------------- /internal/cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/operator-framework/kubectl-operator/internal/version" 9 | ) 10 | 11 | func newVersionCmd() *cobra.Command { 12 | cmd := &cobra.Command{ 13 | Use: "version", 14 | Short: "Print version information", 15 | Run: func(_ *cobra.Command, _ []string) { 16 | fmt.Printf("%#v\n", version.Version) 17 | }, 18 | } 19 | return cmd 20 | } 21 | -------------------------------------------------------------------------------- /internal/pkg/action/action_suite_test.go: -------------------------------------------------------------------------------- 1 | package action_test 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestCommand(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "Internal action Suite") 13 | } 14 | -------------------------------------------------------------------------------- /internal/pkg/action/catalog_add.go: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "time" 9 | 10 | "github.com/containerd/containerd/archive/compression" 11 | "github.com/containerd/containerd/images" 12 | "github.com/containerd/containerd/namespaces" 13 | "github.com/containerd/platforms" 14 | ocispec "github.com/opencontainers/image-spec/specs-go/v1" 15 | apierrors "k8s.io/apimachinery/pkg/api/errors" 16 | "k8s.io/apimachinery/pkg/types" 17 | "k8s.io/apimachinery/pkg/util/wait" 18 | 19 | "github.com/operator-framework/api/pkg/operators/v1alpha1" 20 | "github.com/operator-framework/operator-registry/pkg/image" 21 | "github.com/operator-framework/operator-registry/pkg/image/containerdregistry" 22 | 23 | "github.com/operator-framework/kubectl-operator/internal/pkg/catalogsource" 24 | "github.com/operator-framework/kubectl-operator/pkg/action" 25 | ) 26 | 27 | const ( 28 | alphaDisplayNameLabel = "alpha.operators.operatorframework.io.index.display-name.v1" 29 | alphaPublisherLabel = "alpha.operators.operatorframework.io.index.publisher.v1" 30 | ) 31 | 32 | type CatalogAdd struct { 33 | config *action.Configuration 34 | 35 | CatalogSourceName string 36 | IndexImage string 37 | DisplayName string 38 | Publisher string 39 | CleanupTimeout time.Duration 40 | 41 | Logf func(string, ...interface{}) 42 | RegistryOptions []containerdregistry.RegistryOption 43 | 44 | registry *containerdregistry.Registry 45 | } 46 | 47 | func NewCatalogAdd(cfg *action.Configuration) *CatalogAdd { 48 | return &CatalogAdd{ 49 | config: cfg, 50 | Logf: func(string, ...interface{}) {}, 51 | } 52 | } 53 | 54 | func (a *CatalogAdd) Run(ctx context.Context) (*v1alpha1.CatalogSource, error) { 55 | var err error 56 | a.registry, err = containerdregistry.NewRegistry(a.RegistryOptions...) 57 | if err != nil { 58 | return nil, err 59 | } 60 | 61 | defer func() { 62 | if err := a.registry.Destroy(); err != nil { 63 | a.Logf("registry cleanup: %v", err) 64 | } 65 | }() 66 | 67 | csKey := types.NamespacedName{ 68 | Namespace: a.config.Namespace, 69 | Name: a.CatalogSourceName, 70 | } 71 | 72 | labels, err := a.labelsFor(ctx, a.IndexImage) 73 | if err != nil { 74 | return nil, fmt.Errorf("get image labels: %v", err) 75 | } 76 | 77 | a.setDefaults(labels) 78 | 79 | opts := []catalogsource.Option{ 80 | catalogsource.DisplayName(a.DisplayName), 81 | catalogsource.Publisher(a.Publisher), 82 | catalogsource.Image(a.IndexImage), 83 | } 84 | 85 | cs := catalogsource.Build(csKey, opts...) 86 | if err := a.config.Client.Create(ctx, cs); err != nil { 87 | return nil, fmt.Errorf("create catalogsource: %v", err) 88 | } 89 | 90 | if err := a.waitForCatalogSourceReady(ctx, cs); err != nil { 91 | defer a.cleanup(cs) 92 | return nil, err 93 | } 94 | 95 | return cs, nil 96 | } 97 | 98 | func (a *CatalogAdd) labelsFor(ctx context.Context, indexImage string) (map[string]string, error) { 99 | ref := image.SimpleReference(indexImage) 100 | if err := a.registry.Pull(ctx, ref); err != nil { 101 | return nil, fmt.Errorf("pull image: %v", err) 102 | } 103 | 104 | ctx = namespaces.WithNamespace(ctx, namespaces.Default) 105 | img, err := a.registry.Images().Get(ctx, ref.String()) 106 | if err != nil { 107 | return nil, fmt.Errorf("get image from local registry: %v", err) 108 | } 109 | 110 | manifest, err := images.Manifest(ctx, a.registry.Content(), img.Target, platforms.All) 111 | if err != nil { 112 | return nil, fmt.Errorf("resolve image manifest: %v", err) 113 | } 114 | 115 | ra, err := a.registry.Content().ReaderAt(ctx, manifest.Config) 116 | if err != nil { 117 | return nil, fmt.Errorf("get image reader: %v", err) 118 | } 119 | defer ra.Close() 120 | 121 | decompressed, err := compression.DecompressStream(io.NewSectionReader(ra, 0, ra.Size())) 122 | if err != nil { 123 | return nil, fmt.Errorf("decompress image data: %v", err) 124 | } 125 | var imageMeta ocispec.Image 126 | dec := json.NewDecoder(decompressed) 127 | if err := dec.Decode(&imageMeta); err != nil { 128 | return nil, fmt.Errorf("decode image metadata: %v", err) 129 | } 130 | return imageMeta.Config.Labels, nil 131 | } 132 | 133 | func (a *CatalogAdd) setDefaults(labels map[string]string) { 134 | if a.DisplayName == "" { 135 | if v, ok := labels[alphaDisplayNameLabel]; ok { 136 | a.DisplayName = v 137 | } 138 | } 139 | if a.Publisher == "" { 140 | if v, ok := labels[alphaPublisherLabel]; ok { 141 | a.Publisher = v 142 | } 143 | } 144 | } 145 | 146 | func (a *CatalogAdd) waitForCatalogSourceReady(ctx context.Context, cs *v1alpha1.CatalogSource) error { 147 | csKey := objectKeyForObject(cs) 148 | if err := wait.PollUntilContextCancel(ctx, time.Millisecond*250, true, func(conditionCtx context.Context) (bool, error) { 149 | if err := a.config.Client.Get(conditionCtx, csKey, cs); err != nil { 150 | return false, err 151 | } 152 | if cs.Status.GRPCConnectionState != nil { 153 | if cs.Status.GRPCConnectionState.LastObservedState == "READY" { 154 | return true, nil 155 | } 156 | } 157 | return false, nil 158 | }); err != nil { 159 | return fmt.Errorf("catalogsource connection not ready: %v", err) 160 | } 161 | return nil 162 | } 163 | 164 | func (a *CatalogAdd) cleanup(cs *v1alpha1.CatalogSource) { 165 | ctx, cancel := context.WithTimeout(context.Background(), a.CleanupTimeout) 166 | defer cancel() 167 | if err := a.config.Client.Delete(ctx, cs); err != nil && !apierrors.IsNotFound(err) { 168 | a.Logf("delete catalogsource %q: %v", cs.Name, err) 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /internal/pkg/action/catalog_list.go: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | import ( 4 | "context" 5 | 6 | "sigs.k8s.io/controller-runtime/pkg/client" 7 | 8 | "github.com/operator-framework/api/pkg/operators/v1alpha1" 9 | 10 | "github.com/operator-framework/kubectl-operator/pkg/action" 11 | ) 12 | 13 | type CatalogList struct { 14 | config *action.Configuration 15 | } 16 | 17 | func NewCatalogList(cfg *action.Configuration) *CatalogList { 18 | return &CatalogList{cfg} 19 | } 20 | 21 | func (l *CatalogList) Run(ctx context.Context) ([]v1alpha1.CatalogSource, error) { 22 | css := v1alpha1.CatalogSourceList{} 23 | if err := l.config.Client.List(ctx, &css, client.InNamespace(l.config.Namespace)); err != nil { 24 | return nil, err 25 | } 26 | return css.Items, nil 27 | } 28 | -------------------------------------------------------------------------------- /internal/pkg/action/catalog_remove.go: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/operator-framework/api/pkg/operators/v1alpha1" 8 | 9 | "github.com/operator-framework/kubectl-operator/pkg/action" 10 | ) 11 | 12 | type CatalogRemove struct { 13 | config *action.Configuration 14 | 15 | CatalogName string 16 | } 17 | 18 | func NewCatalogRemove(cfg *action.Configuration) *CatalogRemove { 19 | return &CatalogRemove{ 20 | config: cfg, 21 | } 22 | } 23 | 24 | func (r *CatalogRemove) Run(ctx context.Context) error { 25 | cs := v1alpha1.CatalogSource{} 26 | cs.SetNamespace(r.config.Namespace) 27 | cs.SetName(r.CatalogName) 28 | if err := r.config.Client.Delete(ctx, &cs); err != nil { 29 | return fmt.Errorf("delete catalogsource %q: %v", cs.Name, err) 30 | } 31 | return waitForDeletion(ctx, r.config.Client, &cs) 32 | } 33 | -------------------------------------------------------------------------------- /internal/pkg/action/constants.go: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | const ( 4 | csvKind = "ClusterServiceVersion" 5 | ) 6 | -------------------------------------------------------------------------------- /internal/pkg/action/helpers.go: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "time" 8 | 9 | apierrors "k8s.io/apimachinery/pkg/api/errors" 10 | "k8s.io/apimachinery/pkg/types" 11 | "k8s.io/apimachinery/pkg/util/wait" 12 | "k8s.io/client-go/util/retry" 13 | "sigs.k8s.io/controller-runtime/pkg/client" 14 | 15 | "github.com/operator-framework/api/pkg/operators/v1alpha1" 16 | ) 17 | 18 | func objectKeyForObject(obj client.Object) types.NamespacedName { 19 | return types.NamespacedName{ 20 | Namespace: obj.GetNamespace(), 21 | Name: obj.GetName(), 22 | } 23 | } 24 | 25 | func waitForDeletion(ctx context.Context, cl client.Client, objs ...client.Object) error { 26 | for _, obj := range objs { 27 | obj := obj 28 | lowerKind := strings.ToLower(obj.GetObjectKind().GroupVersionKind().Kind) 29 | key := objectKeyForObject(obj) 30 | if err := wait.PollUntilContextCancel(ctx, 250*time.Millisecond, true, func(conditionCtx context.Context) (bool, error) { 31 | if err := cl.Get(conditionCtx, key, obj); apierrors.IsNotFound(err) { 32 | return true, nil 33 | } else if err != nil { 34 | return false, err 35 | } 36 | return false, nil 37 | }); err != nil { 38 | return fmt.Errorf("wait for %s %q deleted: %v", lowerKind, key.Name, err) 39 | } 40 | } 41 | return nil 42 | } 43 | 44 | func approveInstallPlan(ctx context.Context, cl client.Client, ip *v1alpha1.InstallPlan) error { 45 | ipKey := objectKeyForObject(ip) 46 | return retry.RetryOnConflict(retry.DefaultBackoff, func() error { 47 | if err := cl.Get(ctx, ipKey, ip); err != nil { 48 | return err 49 | } 50 | ip.Spec.Approved = true 51 | return cl.Update(ctx, ip) 52 | }) 53 | } 54 | 55 | func getCSV(ctx context.Context, cl client.Client, ip *v1alpha1.InstallPlan) (*v1alpha1.ClusterServiceVersion, error) { 56 | ipKey := objectKeyForObject(ip) 57 | if err := wait.PollUntilContextCancel(ctx, time.Millisecond*250, true, func(conditionCtx context.Context) (bool, error) { 58 | if err := cl.Get(conditionCtx, ipKey, ip); err != nil { 59 | return false, err 60 | } 61 | if ip.Status.Phase == v1alpha1.InstallPlanPhaseComplete { 62 | return true, nil 63 | } 64 | return false, nil 65 | }); err != nil { 66 | return nil, fmt.Errorf("waiting for operator installation to complete: %v", err) 67 | } 68 | 69 | csvKey := types.NamespacedName{ 70 | Namespace: ipKey.Namespace, 71 | } 72 | for _, s := range ip.Status.Plan { 73 | if s.Resource.Kind == csvKind { 74 | csvKey.Name = s.Resource.Name 75 | } 76 | } 77 | if csvKey.Name == "" { 78 | return nil, fmt.Errorf("could not find installed CSV in install plan") 79 | } 80 | csv := &v1alpha1.ClusterServiceVersion{} 81 | if err := cl.Get(ctx, csvKey, csv); err != nil { 82 | return nil, fmt.Errorf("get clusterserviceversion: %v", err) 83 | } 84 | return csv, nil 85 | } 86 | -------------------------------------------------------------------------------- /internal/pkg/action/operator_list.go: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/operator-framework/api/pkg/operators/v1alpha1" 7 | 8 | "github.com/operator-framework/kubectl-operator/pkg/action" 9 | ) 10 | 11 | type OperatorList struct { 12 | config *action.Configuration 13 | } 14 | 15 | func NewOperatorList(cfg *action.Configuration) *OperatorList { 16 | return &OperatorList{cfg} 17 | } 18 | 19 | func (l *OperatorList) Run(ctx context.Context) ([]v1alpha1.Subscription, error) { 20 | subs := v1alpha1.SubscriptionList{} 21 | if err := l.config.Client.List(ctx, &subs); err != nil { 22 | return nil, err 23 | } 24 | return subs.Items, nil 25 | } 26 | -------------------------------------------------------------------------------- /internal/pkg/action/operator_list_available.go: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "k8s.io/apimachinery/pkg/types" 9 | "sigs.k8s.io/controller-runtime/pkg/client" 10 | 11 | v1 "github.com/operator-framework/operator-lifecycle-manager/pkg/package-server/apis/operators/v1" 12 | 13 | "github.com/operator-framework/kubectl-operator/internal/pkg/operator" 14 | "github.com/operator-framework/kubectl-operator/pkg/action" 15 | ) 16 | 17 | type OperatorListAvailable struct { 18 | config *action.Configuration 19 | 20 | Catalog NamespacedName 21 | Package string 22 | } 23 | 24 | func NewOperatorListAvailable(cfg *action.Configuration) *OperatorListAvailable { 25 | return &OperatorListAvailable{ 26 | config: cfg, 27 | } 28 | } 29 | 30 | func (l *OperatorListAvailable) Run(ctx context.Context) ([]operator.PackageManifest, error) { 31 | labelSelector := client.MatchingLabels{} 32 | if l.Catalog.Name != "" { 33 | labelSelector["catalog"] = l.Catalog.Name 34 | } 35 | if l.Catalog.Namespace != "" { 36 | labelSelector["catalog-namespace"] = l.Catalog.Namespace 37 | } 38 | 39 | if l.Package != "" { 40 | pm := v1.PackageManifest{} 41 | if err := l.config.Client.Get(ctx, types.NamespacedName{Name: l.Package, Namespace: l.config.Namespace}, &pm); err != nil { 42 | return nil, err 43 | } 44 | return []operator.PackageManifest{{PackageManifest: pm}}, nil 45 | } 46 | 47 | pms := v1.PackageManifestList{} 48 | if err := l.config.Client.List(ctx, &pms, labelSelector, client.InNamespace(l.config.Namespace)); err != nil { 49 | return nil, err 50 | } 51 | pkgs := make([]operator.PackageManifest, 0, len(pms.Items)) 52 | for _, pm := range pms.Items { 53 | pkgs = append(pkgs, operator.PackageManifest{PackageManifest: pm}) 54 | } 55 | return pkgs, nil 56 | } 57 | 58 | type NamespacedName struct { 59 | types.NamespacedName 60 | } 61 | 62 | func (f *NamespacedName) Set(str string) error { 63 | split := strings.Split(str, "/") 64 | switch len(split) { 65 | case 0: 66 | case 1: 67 | f.Name = split[0] 68 | case 2: 69 | f.Namespace = split[0] 70 | f.Name = split[1] 71 | default: 72 | return fmt.Errorf("invalid namespaced name value %q", str) 73 | } 74 | return nil 75 | } 76 | 77 | func (f NamespacedName) String() string { 78 | return f.NamespacedName.String() 79 | } 80 | 81 | func (f NamespacedName) Type() string { 82 | return "NamespacedNameValue" 83 | } 84 | -------------------------------------------------------------------------------- /internal/pkg/action/operator_uninstall.go: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "time" 8 | 9 | apierrors "k8s.io/apimachinery/pkg/api/errors" 10 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 11 | "k8s.io/apimachinery/pkg/types" 12 | "k8s.io/apimachinery/pkg/util/wait" 13 | "sigs.k8s.io/controller-runtime/pkg/client" 14 | 15 | v1 "github.com/operator-framework/api/pkg/operators/v1" 16 | "github.com/operator-framework/api/pkg/operators/v1alpha1" 17 | 18 | "github.com/operator-framework/kubectl-operator/internal/pkg/operand" 19 | "github.com/operator-framework/kubectl-operator/pkg/action" 20 | ) 21 | 22 | type OperatorUninstall struct { 23 | config *action.Configuration 24 | 25 | Package string 26 | OperandStrategy operand.DeletionStrategy 27 | DeleteAll bool 28 | DeleteOperator bool 29 | DeleteOperatorGroups bool 30 | DeleteOperatorGroupNames []string 31 | Logf func(string, ...interface{}) 32 | } 33 | 34 | func NewOperatorUninstall(cfg *action.Configuration) *OperatorUninstall { 35 | return &OperatorUninstall{ 36 | config: cfg, 37 | OperandStrategy: operand.Abort, 38 | Logf: func(string, ...interface{}) {}, 39 | } 40 | } 41 | 42 | type ErrPackageNotFound struct { 43 | PackageName string 44 | } 45 | 46 | func (e ErrPackageNotFound) Error() string { 47 | return fmt.Sprintf("package %q not found", e.PackageName) 48 | } 49 | 50 | func (u *OperatorUninstall) Run(ctx context.Context) error { 51 | if u.DeleteAll { 52 | u.DeleteOperator = true 53 | u.DeleteOperatorGroups = true 54 | } 55 | if u.DeleteOperator { 56 | u.OperandStrategy = operand.Delete 57 | } 58 | 59 | if err := u.OperandStrategy.Valid(); err != nil { 60 | return err 61 | } 62 | 63 | subs := v1alpha1.SubscriptionList{} 64 | if err := u.config.Client.List(ctx, &subs, client.InNamespace(u.config.Namespace)); err != nil { 65 | return fmt.Errorf("list subscriptions: %v", err) 66 | } 67 | 68 | var sub *v1alpha1.Subscription 69 | for _, s := range subs.Items { 70 | s := s 71 | if u.Package == s.Spec.Package { 72 | sub = &s 73 | break 74 | } 75 | } 76 | if sub == nil { 77 | return &ErrPackageNotFound{u.Package} 78 | } 79 | 80 | csv, csvName, err := u.getSubscriptionCSV(ctx, sub) 81 | if err != nil && !apierrors.IsNotFound(err) { 82 | if csvName == "" { 83 | return fmt.Errorf("get subscription csv: %v", err) 84 | } 85 | return fmt.Errorf("get subscription csv %q: %v", csvName, err) 86 | } 87 | 88 | // find operands related to the operator on cluster 89 | lister := action.NewOperatorListOperands(u.config) 90 | operands, err := lister.Run(ctx, u.Package) 91 | if err != nil { 92 | return fmt.Errorf("list operands for operator %q: %v", u.Package, err) 93 | } 94 | // validate the provided deletion strategy before proceeding to deletion 95 | if err := u.validStrategy(operands); err != nil { 96 | return fmt.Errorf("could not proceed with deletion of %q: %w", u.Package, err) 97 | } 98 | 99 | /* 100 | Deletion order: 101 | 1. Subscription to prevent further installs or upgrades of the operator while cleaning up. 102 | 103 | If the CSV exists: 104 | 2. Operands so the operator has a chance to handle CRs that have finalizers. 105 | Note: the correct strategy must be chosen in order to process an opertor delete with operand 106 | on-cluster. 107 | 3. ClusterServiceVersion. OLM puts an ownerref on every namespaced resource to the CSV, 108 | and an owner label on every cluster scoped resource so they get gc'd on deletion. 109 | 110 | 4. The Operator and all objects referenced by it if Operator deletion is specified 111 | 5. OperatorGroup in the namespace if no other subscriptions are in that namespace and OperatorGroup deletion 112 | is specified 113 | */ 114 | 115 | // Subscriptions can be deleted asynchronously. 116 | if err := u.deleteObjects(ctx, sub); err != nil { 117 | return err 118 | } 119 | 120 | // If we could not find a csv associated with the subscription, that likely 121 | // means there is no CSV associated with it yet. Delete non-CSV related items only like the operatorgroup. 122 | if csv == nil { 123 | u.Logf("csv for package %q not found", u.Package) 124 | } else { 125 | if err := u.deleteCSVRelatedResources(ctx, csv, operands); err != nil { 126 | return err 127 | } 128 | } 129 | 130 | if u.DeleteOperator { 131 | if err := u.deleteOperator(ctx); err != nil { 132 | return fmt.Errorf("delete operator: %v", err) 133 | } 134 | } 135 | 136 | if u.DeleteOperatorGroups { 137 | if err := u.deleteOperatorGroup(ctx); err != nil { 138 | return fmt.Errorf("delete operatorgroup: %v", err) 139 | } 140 | } 141 | 142 | return nil 143 | } 144 | 145 | func (u *OperatorUninstall) operatorName() string { 146 | return fmt.Sprintf("%s.%s", u.Package, u.config.Namespace) 147 | } 148 | 149 | func (u *OperatorUninstall) deleteObjects(ctx context.Context, objs ...client.Object) error { 150 | for _, obj := range objs { 151 | obj := obj 152 | lowerKind := strings.ToLower(obj.GetObjectKind().GroupVersionKind().Kind) 153 | if err := u.config.Client.Delete(ctx, obj); err != nil && !apierrors.IsNotFound(err) { 154 | return fmt.Errorf("delete %s %q: %v", lowerKind, obj.GetName(), err) 155 | } else if err == nil { 156 | u.Logf("%s %q deleted", lowerKind, obj.GetName()) 157 | } 158 | } 159 | return waitForDeletion(ctx, u.config.Client, objs...) 160 | } 161 | 162 | // getSubscriptionCSV looks up the installed CSV name from the provided subscription and fetches it. 163 | func (u *OperatorUninstall) getSubscriptionCSV(ctx context.Context, subscription *v1alpha1.Subscription) (*v1alpha1.ClusterServiceVersion, string, error) { 164 | name := csvNameFromSubscription(subscription) 165 | 166 | // If we could not find a name in the subscription, that likely 167 | // means there is no CSV associated with it yet. This should 168 | // not be treated as an error, so return a nil CSV with a nil error. 169 | if name == "" { 170 | return nil, "", nil 171 | } 172 | 173 | key := types.NamespacedName{ 174 | Name: name, 175 | Namespace: subscription.GetNamespace(), 176 | } 177 | 178 | csv := &v1alpha1.ClusterServiceVersion{} 179 | if err := u.config.Client.Get(ctx, key, csv); err != nil { 180 | return nil, name, err 181 | } 182 | csv.SetGroupVersionKind(v1alpha1.SchemeGroupVersion.WithKind(csvKind)) 183 | return csv, name, nil 184 | } 185 | 186 | // deleteOperator deletes the operator and everything it references. It: 187 | // - gets the operator object so that we can look up its references 188 | // - deletes the references 189 | // - waits until the operator object references are all deleted (this step is 190 | // necessary because OLM recreates the operator object until no other 191 | // referenced objects exist) 192 | // - deletes the operator 193 | func (u *OperatorUninstall) deleteOperator(ctx context.Context) error { 194 | // get the operator 195 | var op v1.Operator 196 | key := types.NamespacedName{Name: u.operatorName()} 197 | if err := u.config.Client.Get(ctx, key, &op); err != nil { 198 | if apierrors.IsNotFound(err) { 199 | return nil 200 | } 201 | return fmt.Errorf("get operator: %w", err) 202 | } 203 | 204 | // build objects for each of the references and then delete them 205 | objs := []client.Object{} 206 | for _, ref := range op.Status.Components.Refs { 207 | obj := unstructured.Unstructured{} 208 | obj.SetName(ref.Name) 209 | obj.SetNamespace(ref.Namespace) 210 | obj.SetGroupVersionKind(ref.GroupVersionKind()) 211 | objs = append(objs, &obj) 212 | } 213 | if err := u.deleteObjects(ctx, objs...); err != nil { 214 | return fmt.Errorf("delete operator references: %v", err) 215 | } 216 | 217 | // wait until all of the objects we just deleted disappear from the 218 | // operator's references. 219 | if err := wait.PollUntilContextCancel(ctx, time.Millisecond*100, true, func(conditionCtx context.Context) (bool, error) { 220 | var check v1.Operator 221 | if err := u.config.Client.Get(conditionCtx, key, &check); err != nil { 222 | if apierrors.IsNotFound(err) { 223 | return true, nil 224 | } 225 | return false, fmt.Errorf("get operator: %w", err) 226 | } 227 | if check.Status.Components == nil || len(check.Status.Components.Refs) == 0 { 228 | return true, nil 229 | } 230 | return false, nil 231 | }); err != nil { 232 | return err 233 | } 234 | 235 | // delete the operator 236 | op.SetGroupVersionKind(v1.GroupVersion.WithKind("Operator")) 237 | if err := u.deleteObjects(ctx, &op); err != nil { 238 | return fmt.Errorf("delete operator: %v", err) 239 | } 240 | 241 | return nil 242 | } 243 | 244 | func (u *OperatorUninstall) deleteOperatorGroup(ctx context.Context) error { 245 | subs := v1alpha1.SubscriptionList{} 246 | if err := u.config.Client.List(ctx, &subs, client.InNamespace(u.config.Namespace)); err != nil { 247 | return fmt.Errorf("list subscriptions: %v", err) 248 | } 249 | 250 | // If there are no subscriptions left, delete the operator group(s). 251 | if len(subs.Items) == 0 { 252 | ogs := v1.OperatorGroupList{} 253 | if err := u.config.Client.List(ctx, &ogs, client.InNamespace(u.config.Namespace)); err != nil { 254 | return fmt.Errorf("list operatorgroups: %v", err) 255 | } 256 | for _, og := range ogs.Items { 257 | og := og 258 | if len(u.DeleteOperatorGroupNames) == 0 || contains(u.DeleteOperatorGroupNames, og.GetName()) { 259 | if err := u.deleteObjects(ctx, &og); err != nil { 260 | return err 261 | } 262 | } 263 | } 264 | } 265 | return nil 266 | } 267 | 268 | // validStrategy validates the deletion strategy against the operands on-cluster 269 | func (u *OperatorUninstall) validStrategy(operands *unstructured.UnstructuredList) error { 270 | if len(operands.Items) > 0 && u.OperandStrategy == operand.Abort { 271 | return operand.ErrAbortStrategy 272 | } 273 | return nil 274 | } 275 | 276 | func (u *OperatorUninstall) deleteCSVRelatedResources(ctx context.Context, csv *v1alpha1.ClusterServiceVersion, operands *unstructured.UnstructuredList) error { 277 | switch u.OperandStrategy { 278 | case operand.Ignore: 279 | for _, op := range operands.Items { 280 | u.Logf("%s %q orphaned", strings.ToLower(op.GetKind()), prettyPrint(op)) 281 | } 282 | case operand.Delete: 283 | for _, op := range operands.Items { 284 | op := op 285 | if err := u.deleteObjects(ctx, &op); err != nil { 286 | return fmt.Errorf("delete operand: %v", err) 287 | } 288 | } 289 | } 290 | 291 | // OLM puts an ownerref on every namespaced resource to the CSV, 292 | // and an owner label on every cluster scoped resource. When CSV is deleted 293 | // kube and olm gc will remove all the referenced resources. 294 | if err := u.deleteObjects(ctx, csv); err != nil { 295 | return fmt.Errorf("delete csv: %v", err) 296 | } 297 | 298 | return nil 299 | } 300 | 301 | func csvNameFromSubscription(subscription *v1alpha1.Subscription) string { 302 | if subscription.Status.InstalledCSV != "" { 303 | return subscription.Status.InstalledCSV 304 | } 305 | return subscription.Status.CurrentCSV 306 | } 307 | 308 | func contains(haystack []string, needle string) bool { 309 | for _, n := range haystack { 310 | if n == needle { 311 | return true 312 | } 313 | } 314 | return false 315 | } 316 | 317 | func prettyPrint(op unstructured.Unstructured) string { 318 | namespaced := op.GetNamespace() != "" 319 | if namespaced { 320 | return fmt.Sprint(op.GetName() + "/" + op.GetNamespace()) 321 | } 322 | return op.GetName() 323 | } 324 | -------------------------------------------------------------------------------- /internal/pkg/action/operator_uninstall_test.go: -------------------------------------------------------------------------------- 1 | package action_test 2 | 3 | import ( 4 | "context" 5 | 6 | . "github.com/onsi/ginkgo" 7 | . "github.com/onsi/gomega" 8 | 9 | corev1 "k8s.io/api/core/v1" 10 | apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" 11 | apierrors "k8s.io/apimachinery/pkg/api/errors" 12 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 13 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 14 | "k8s.io/apimachinery/pkg/runtime/schema" 15 | "k8s.io/apimachinery/pkg/types" 16 | "sigs.k8s.io/controller-runtime/pkg/client/fake" 17 | 18 | v1 "github.com/operator-framework/api/pkg/operators/v1" 19 | "github.com/operator-framework/api/pkg/operators/v1alpha1" 20 | 21 | internalaction "github.com/operator-framework/kubectl-operator/internal/pkg/action" 22 | "github.com/operator-framework/kubectl-operator/internal/pkg/operand" 23 | "github.com/operator-framework/kubectl-operator/pkg/action" 24 | ) 25 | 26 | var _ = Describe("OperatorUninstall", func() { 27 | const etcd = "etcd" 28 | var ( 29 | cfg action.Configuration 30 | operator *v1.Operator 31 | csv *v1alpha1.ClusterServiceVersion 32 | crd *apiextensionsv1.CustomResourceDefinition 33 | og *v1.OperatorGroup 34 | sub *v1alpha1.Subscription 35 | etcdcluster1 *unstructured.Unstructured 36 | etcdcluster2 *unstructured.Unstructured 37 | etcdcluster3 *unstructured.Unstructured 38 | ) 39 | 40 | BeforeEach(func() { 41 | sch, err := action.NewScheme() 42 | Expect(err).To(BeNil()) 43 | 44 | etcdclusterGVK := schema.GroupVersionKind{ 45 | Group: "etcd.database.coreos.com", 46 | Version: "v1beta2", 47 | Kind: "EtcdCluster", 48 | } 49 | 50 | sch.AddKnownTypeWithName(etcdclusterGVK, &unstructured.Unstructured{}) 51 | sch.AddKnownTypeWithName(schema.GroupVersionKind{ 52 | Group: "etcd.database.coreos.com", 53 | Version: "v1beta2", 54 | Kind: "EtcdClusterList", 55 | }, &unstructured.UnstructuredList{}) 56 | 57 | operator = &v1.Operator{ 58 | ObjectMeta: metav1.ObjectMeta{Name: "etcd.etcd-namespace"}, 59 | Status: v1.OperatorStatus{ 60 | Components: &v1.Components{ 61 | Refs: []v1.RichReference{ 62 | { 63 | ObjectReference: &corev1.ObjectReference{ 64 | APIVersion: "operators.coreos.com/v1alpha1", 65 | Kind: "ClusterServiceVersion", 66 | Name: "etcdoperator.v0.9.4-clusterwide", 67 | Namespace: "etcd-namespace", 68 | }, 69 | }, 70 | }, 71 | }, 72 | }, 73 | } 74 | 75 | csv = &v1alpha1.ClusterServiceVersion{ 76 | ObjectMeta: metav1.ObjectMeta{ 77 | Name: "etcdoperator.v0.9.4-clusterwide", 78 | Namespace: "etcd-namespace", 79 | }, 80 | Spec: v1alpha1.ClusterServiceVersionSpec{ 81 | CustomResourceDefinitions: v1alpha1.CustomResourceDefinitions{ 82 | Owned: []v1alpha1.CRDDescription{ 83 | { 84 | Name: "etcdclusters.etcd.database.coreos.com", 85 | Version: "v1beta2", 86 | Kind: "EtcdCluster", 87 | }, 88 | }, 89 | }, 90 | }, 91 | Status: v1alpha1.ClusterServiceVersionStatus{Phase: v1alpha1.CSVPhaseSucceeded}, 92 | } 93 | 94 | og = &v1.OperatorGroup{ 95 | ObjectMeta: metav1.ObjectMeta{ 96 | Name: "etcd", 97 | Namespace: "etcd-namespace", 98 | }, 99 | Status: v1.OperatorGroupStatus{Namespaces: []string{""}}, 100 | } 101 | 102 | sub = &v1alpha1.Subscription{ 103 | ObjectMeta: metav1.ObjectMeta{ 104 | Name: "etcd-sub", 105 | Namespace: "etcd-namespace", 106 | }, 107 | Spec: &v1alpha1.SubscriptionSpec{ 108 | Package: "etcd", 109 | }, 110 | Status: v1alpha1.SubscriptionStatus{ 111 | InstalledCSV: "etcdoperator.v0.9.4-clusterwide", 112 | }, 113 | } 114 | 115 | crd = &apiextensionsv1.CustomResourceDefinition{ 116 | ObjectMeta: metav1.ObjectMeta{ 117 | Name: "etcdclusters.etcd.database.coreos.com", 118 | }, 119 | Spec: apiextensionsv1.CustomResourceDefinitionSpec{ 120 | Group: "etcd.database.coreos.com", 121 | Names: apiextensionsv1.CustomResourceDefinitionNames{ 122 | ListKind: "EtcdClusterList", 123 | }, 124 | }, 125 | } 126 | etcdcluster1 = &unstructured.Unstructured{} 127 | etcdcluster1.SetGroupVersionKind(etcdclusterGVK) 128 | etcdcluster1.SetNamespace("ns1") 129 | etcdcluster1.SetName("cluster1") 130 | 131 | etcdcluster2 = &unstructured.Unstructured{} 132 | etcdcluster2.SetGroupVersionKind(etcdclusterGVK) 133 | etcdcluster2.SetNamespace("ns2") 134 | etcdcluster2.SetName("cluster2") 135 | 136 | etcdcluster3 = &unstructured.Unstructured{} 137 | etcdcluster3.SetGroupVersionKind(etcdclusterGVK) 138 | // Empty namespace to simulate cluster-scoped object. 139 | etcdcluster3.SetNamespace("") 140 | etcdcluster3.SetName("cluster3") 141 | 142 | cl := fake.NewClientBuilder(). 143 | WithObjects(operator, csv, og, sub, crd, etcdcluster1, etcdcluster2, etcdcluster3). 144 | WithScheme(sch). 145 | Build() 146 | cfg.Scheme = sch 147 | cfg.Client = cl 148 | cfg.Namespace = "etcd-namespace" 149 | }) 150 | 151 | It("should fail due to missing subscription", func() { 152 | uninstaller := internalaction.NewOperatorUninstall(&cfg) 153 | // switch to package without a subscription for it 154 | uninstaller.Package = "redis" 155 | err := uninstaller.Run(context.TODO()) 156 | Expect(err).To(MatchError(&internalaction.ErrPackageNotFound{PackageName: "redis"})) 157 | }) 158 | 159 | It("should not fail due to missing csv", func() { 160 | // switch to missing csv 161 | // this is not an error condition, we simply delete the subscription and exit 162 | sub.Status.InstalledCSV = "" 163 | Expect(cfg.Client.Update(context.TODO(), sub)).To(Succeed()) 164 | 165 | uninstaller := internalaction.NewOperatorUninstall(&cfg) 166 | uninstaller.Package = etcd 167 | uninstaller.OperandStrategy = operand.Ignore 168 | err := uninstaller.Run(context.TODO()) 169 | Expect(err).To(BeNil()) 170 | 171 | subKey := types.NamespacedName{Name: "etcd-sub", Namespace: "etcd-namespace"} 172 | s := &v1alpha1.Subscription{} 173 | Expect(cfg.Client.Get(context.TODO(), subKey, s)).To(WithTransform(apierrors.IsNotFound, BeTrue())) 174 | }) 175 | 176 | It("should fail due to invalid operand deletion strategy", func() { 177 | uninstaller := internalaction.NewOperatorUninstall(&cfg) 178 | uninstaller.Package = etcd 179 | uninstaller.OperandStrategy = "foo" 180 | err := uninstaller.Run(context.TODO()) 181 | Expect(err.Error()).To(ContainSubstring("unknown operand deletion strategy")) 182 | }) 183 | 184 | It("should error with operands on cluster when default abort strategy is set", func() { 185 | uninstaller := internalaction.NewOperatorUninstall(&cfg) 186 | uninstaller.Package = etcd 187 | uninstaller.OperandStrategy = operand.Abort 188 | err := uninstaller.Run(context.TODO()) 189 | Expect(err).To(MatchError(operand.ErrAbortStrategy)) 190 | }) 191 | 192 | It("should ignore operands and delete sub and csv when ignore strategy is set", func() { 193 | uninstaller := internalaction.NewOperatorUninstall(&cfg) 194 | uninstaller.Package = etcd 195 | uninstaller.OperandStrategy = operand.Ignore 196 | err := uninstaller.Run(context.TODO()) 197 | Expect(err).To(BeNil()) 198 | 199 | subKey := types.NamespacedName{Name: "etcd-sub", Namespace: "etcd-namespace"} 200 | s := &v1alpha1.Subscription{} 201 | Expect(cfg.Client.Get(context.TODO(), subKey, s)).To(WithTransform(apierrors.IsNotFound, BeTrue())) 202 | 203 | csvKey := types.NamespacedName{Name: "etcdoperator.v0.9.4-clusterwide", Namespace: "etcd-namespace"} 204 | csv := &v1alpha1.ClusterServiceVersion{} 205 | Expect(cfg.Client.Get(context.TODO(), csvKey, csv)).To(WithTransform(apierrors.IsNotFound, BeTrue())) 206 | 207 | //check operands are still around 208 | etcd1Key := types.NamespacedName{Name: "cluster1", Namespace: "ns1"} 209 | Expect(cfg.Client.Get(context.TODO(), etcd1Key, etcdcluster1)).To(Succeed()) 210 | 211 | etcd2Key := types.NamespacedName{Name: "cluster2", Namespace: "ns2"} 212 | Expect(cfg.Client.Get(context.TODO(), etcd2Key, etcdcluster2)).To(Succeed()) 213 | 214 | etcd3Key := types.NamespacedName{Name: "cluster3"} 215 | Expect(cfg.Client.Get(context.TODO(), etcd3Key, etcdcluster3)).To(Succeed()) 216 | }) 217 | 218 | It("should delete sub, csv, and operands when delete strategy is set", func() { 219 | uninstaller := internalaction.NewOperatorUninstall(&cfg) 220 | uninstaller.Package = etcd 221 | uninstaller.OperandStrategy = operand.Delete 222 | err := uninstaller.Run(context.TODO()) 223 | Expect(err).To(BeNil()) 224 | 225 | subKey := types.NamespacedName{Name: "etcd-sub", Namespace: "etcd-namespace"} 226 | s := &v1alpha1.Subscription{} 227 | Expect(cfg.Client.Get(context.TODO(), subKey, s)).To(WithTransform(apierrors.IsNotFound, BeTrue())) 228 | 229 | csvKey := types.NamespacedName{Name: "etcdoperator.v0.9.4-clusterwide", Namespace: "etcd-namespace"} 230 | csv := &v1alpha1.ClusterServiceVersion{} 231 | Expect(cfg.Client.Get(context.TODO(), csvKey, csv)).To(WithTransform(apierrors.IsNotFound, BeTrue())) 232 | 233 | etcd1Key := types.NamespacedName{Name: "cluster1", Namespace: "ns1"} 234 | Expect(cfg.Client.Get(context.TODO(), etcd1Key, etcdcluster1)).To(WithTransform(apierrors.IsNotFound, BeTrue())) 235 | 236 | etcd2Key := types.NamespacedName{Name: "cluster2", Namespace: "ns2"} 237 | Expect(cfg.Client.Get(context.TODO(), etcd2Key, etcdcluster2)).To(WithTransform(apierrors.IsNotFound, BeTrue())) 238 | 239 | etcd3Key := types.NamespacedName{Name: "cluster3"} 240 | Expect(cfg.Client.Get(context.TODO(), etcd3Key, etcdcluster3)).To(WithTransform(apierrors.IsNotFound, BeTrue())) 241 | }) 242 | It("should delete sub and operatorgroup when no CSV is found", func() { 243 | uninstaller := internalaction.NewOperatorUninstall(&cfg) 244 | uninstaller.Package = etcd 245 | uninstaller.OperandStrategy = operand.Ignore 246 | uninstaller.DeleteOperatorGroups = true 247 | 248 | sub.Status.InstalledCSV = "foo" // returns nil CSV 249 | Expect(cfg.Client.Update(context.TODO(), sub)).To(Succeed()) 250 | 251 | err := uninstaller.Run(context.TODO()) 252 | Expect(err).To(BeNil()) 253 | 254 | subKey := types.NamespacedName{Name: "etcd-sub", Namespace: "etcd-namespace"} 255 | s := &v1alpha1.Subscription{} 256 | Expect(cfg.Client.Get(context.TODO(), subKey, s)).To(WithTransform(apierrors.IsNotFound, BeTrue())) 257 | 258 | ogKey := types.NamespacedName{Name: "etcd", Namespace: "etcd-namespace"} 259 | og := &v1.OperatorGroup{} 260 | Expect(cfg.Client.Get(context.TODO(), ogKey, og)).To(WithTransform(apierrors.IsNotFound, BeTrue())) 261 | }) 262 | }) 263 | -------------------------------------------------------------------------------- /internal/pkg/action/operator_upgrade.go: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "k8s.io/apimachinery/pkg/types" 8 | "sigs.k8s.io/controller-runtime/pkg/client" 9 | 10 | "github.com/operator-framework/api/pkg/operators/v1alpha1" 11 | 12 | "github.com/operator-framework/kubectl-operator/pkg/action" 13 | ) 14 | 15 | type OperatorUpgrade struct { 16 | config *action.Configuration 17 | 18 | Package string 19 | Channel string 20 | } 21 | 22 | func NewOperatorUpgrade(cfg *action.Configuration) *OperatorUpgrade { 23 | return &OperatorUpgrade{ 24 | config: cfg, 25 | } 26 | } 27 | 28 | func (u *OperatorUpgrade) Run(ctx context.Context) (*v1alpha1.ClusterServiceVersion, error) { 29 | sub, err := u.findSubscriptionForPackage(ctx) 30 | if err != nil { 31 | return nil, err 32 | } 33 | 34 | ip, err := u.getInstallPlan(ctx, sub) 35 | if err != nil { 36 | return nil, err 37 | } 38 | 39 | if err := approveInstallPlan(ctx, u.config.Client, ip); err != nil { 40 | return nil, fmt.Errorf("approve install plan: %v", err) 41 | } 42 | 43 | csv, err := getCSV(ctx, u.config.Client, ip) 44 | if err != nil { 45 | return nil, fmt.Errorf("get clusterserviceversion: %v", err) 46 | } 47 | return csv, nil 48 | } 49 | 50 | func (u *OperatorUpgrade) findSubscriptionForPackage(ctx context.Context) (*v1alpha1.Subscription, error) { 51 | subs := v1alpha1.SubscriptionList{} 52 | if err := u.config.Client.List(ctx, &subs, client.InNamespace(u.config.Namespace)); err != nil { 53 | return nil, fmt.Errorf("list subscriptions: %v", err) 54 | } 55 | 56 | for _, s := range subs.Items { 57 | s := s 58 | if u.Package == s.Spec.Package { 59 | return &s, nil 60 | } 61 | } 62 | return nil, fmt.Errorf("subscription for package %q not found", u.Package) 63 | } 64 | 65 | func (u *OperatorUpgrade) getInstallPlan(ctx context.Context, sub *v1alpha1.Subscription) (*v1alpha1.InstallPlan, error) { 66 | if sub.Status.InstallPlanRef == nil { 67 | return nil, fmt.Errorf("subscription does not reference an install plan") 68 | } 69 | if sub.Status.InstalledCSV == sub.Status.CurrentCSV { 70 | return nil, fmt.Errorf("operator is already at latest version") 71 | } 72 | 73 | ip := v1alpha1.InstallPlan{} 74 | ipKey := types.NamespacedName{ 75 | Namespace: sub.Status.InstallPlanRef.Namespace, 76 | Name: sub.Status.InstallPlanRef.Name, 77 | } 78 | if err := u.config.Client.Get(ctx, ipKey, &ip); err != nil { 79 | return nil, fmt.Errorf("get install plan: %v", err) 80 | } 81 | return &ip, nil 82 | } 83 | -------------------------------------------------------------------------------- /internal/pkg/catalogsource/catalogsource.go: -------------------------------------------------------------------------------- 1 | package catalogsource 2 | 3 | import ( 4 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 5 | "k8s.io/apimachinery/pkg/types" 6 | 7 | "github.com/operator-framework/api/pkg/operators/v1alpha1" 8 | ) 9 | 10 | type Option func(*v1alpha1.CatalogSource) 11 | 12 | func DisplayName(v string) Option { 13 | return func(cs *v1alpha1.CatalogSource) { 14 | cs.Spec.DisplayName = v 15 | } 16 | } 17 | func Image(v string) Option { 18 | return func(cs *v1alpha1.CatalogSource) { 19 | cs.Spec.Image = v 20 | } 21 | } 22 | 23 | func Publisher(v string) Option { 24 | return func(cs *v1alpha1.CatalogSource) { 25 | cs.Spec.Publisher = v 26 | } 27 | } 28 | 29 | func Build(key types.NamespacedName, opts ...Option) *v1alpha1.CatalogSource { 30 | cs := &v1alpha1.CatalogSource{ 31 | ObjectMeta: metav1.ObjectMeta{ 32 | Name: key.Name, 33 | Namespace: key.Namespace, 34 | }, 35 | Spec: v1alpha1.CatalogSourceSpec{ 36 | SourceType: v1alpha1.SourceTypeGrpc, 37 | }, 38 | } 39 | for _, o := range opts { 40 | o(cs) 41 | } 42 | return cs 43 | } 44 | -------------------------------------------------------------------------------- /internal/pkg/operand/strategy.go: -------------------------------------------------------------------------------- 1 | package operand 2 | 3 | import ( 4 | "errors" 5 | "flag" 6 | "fmt" 7 | ) 8 | 9 | // DeletionStrategy describes how to handle operands on-cluster when deleting the associated operator. 10 | type DeletionStrategy string 11 | 12 | var _ flag.Value = new(DeletionStrategy) 13 | 14 | const ( 15 | // Abort is the default deletion strategy: it will abort the deletion operation if operands are on-cluster. 16 | Abort DeletionStrategy = "abort" 17 | // Ignore will ignore the operands when deleting the operator, in effect orphaning them. 18 | Ignore DeletionStrategy = "ignore" 19 | // Delete will delete the operands associated with the operator before deleting the operator, allowing finalizers to run. 20 | Delete DeletionStrategy = "delete" 21 | ) 22 | 23 | func (d *DeletionStrategy) Set(str string) error { 24 | *d = DeletionStrategy(str) 25 | return d.Valid() 26 | } 27 | 28 | func (d DeletionStrategy) String() string { 29 | return string(d) 30 | } 31 | 32 | func (d DeletionStrategy) Valid() error { 33 | switch d { 34 | case Abort, Ignore, Delete: 35 | return nil 36 | } 37 | return fmt.Errorf("unknown operand deletion strategy %q", d) 38 | } 39 | 40 | func (d DeletionStrategy) Type() string { 41 | return "DeletionStrategy" 42 | } 43 | 44 | var ErrAbortStrategy = errors.New(`operand deletion aborted: one or more operands exist and operand strategy is "abort"`) 45 | -------------------------------------------------------------------------------- /internal/pkg/operator/package.go: -------------------------------------------------------------------------------- 1 | package operator 2 | 3 | import ( 4 | "fmt" 5 | 6 | "k8s.io/apimachinery/pkg/util/sets" 7 | 8 | operatorsv1 "github.com/operator-framework/operator-lifecycle-manager/pkg/package-server/apis/operators/v1" 9 | ) 10 | 11 | type PackageManifest struct { 12 | operatorsv1.PackageManifest 13 | } 14 | 15 | // DefaultChannel is the default argument to specify with GetChannel when you want to get the package's default channel. 16 | const DefaultChannel = "" 17 | 18 | // GetChannel returns the specified package channel. DefaultChannel can be used to fetch the package's default channel. 19 | func (pm PackageManifest) GetChannel(channel string) (*PackageChannel, error) { 20 | if channel == DefaultChannel { 21 | defaultChannel := pm.GetDefaultChannel() 22 | if defaultChannel == "" { 23 | return nil, ErrNoDefaultChannel{pm.GetName()} 24 | } 25 | channel = defaultChannel 26 | } 27 | 28 | var packageChannel *operatorsv1.PackageChannel 29 | for _, ch := range pm.Status.Channels { 30 | ch := ch 31 | if ch.Name == channel { 32 | packageChannel = &ch 33 | break 34 | } 35 | } 36 | if packageChannel == nil { 37 | return nil, ErrChannelNotFound{ChannelName: channel, PackageName: pm.GetName()} 38 | } 39 | return &PackageChannel{PackageChannel: *packageChannel}, nil 40 | } 41 | 42 | type PackageChannel struct { 43 | operatorsv1.PackageChannel 44 | } 45 | 46 | func (pc PackageChannel) GetSupportedInstallModes() sets.Set[string] { 47 | supported := sets.New[string]() 48 | for _, im := range pc.CurrentCSVDesc.InstallModes { 49 | if im.Supported { 50 | supported.Insert(string(im.Type)) 51 | } 52 | } 53 | return supported 54 | } 55 | 56 | type ErrNoDefaultChannel struct { 57 | PackageName string 58 | } 59 | 60 | func (e ErrNoDefaultChannel) Error() string { 61 | return fmt.Sprintf("package %q does not have a default channel", e.PackageName) 62 | } 63 | 64 | type ErrChannelNotFound struct { 65 | PackageName string 66 | ChannelName string 67 | } 68 | 69 | func (e ErrChannelNotFound) Error() string { 70 | return fmt.Sprintf("channel %q does not exist for package %q", e.ChannelName, e.PackageName) 71 | } 72 | -------------------------------------------------------------------------------- /internal/pkg/subscription/subscription.go: -------------------------------------------------------------------------------- 1 | package subscription 2 | 3 | import ( 4 | "fmt" 5 | 6 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 7 | "k8s.io/apimachinery/pkg/types" 8 | 9 | "github.com/operator-framework/api/pkg/operators/v1alpha1" 10 | ) 11 | 12 | type Option func(*v1alpha1.Subscription) 13 | 14 | func InstallPlanApproval(v v1alpha1.Approval) Option { 15 | return func(s *v1alpha1.Subscription) { 16 | s.Spec.InstallPlanApproval = v 17 | } 18 | } 19 | 20 | func StartingCSV(v string) Option { 21 | return func(s *v1alpha1.Subscription) { 22 | s.Spec.StartingCSV = v 23 | } 24 | } 25 | 26 | func Build(key types.NamespacedName, channel string, source types.NamespacedName, opts ...Option) *v1alpha1.Subscription { 27 | s := &v1alpha1.Subscription{ 28 | ObjectMeta: metav1.ObjectMeta{ 29 | Name: key.Name, 30 | Namespace: key.Namespace, 31 | }, 32 | Spec: &v1alpha1.SubscriptionSpec{ 33 | Package: key.Name, 34 | Channel: channel, 35 | CatalogSource: source.Name, 36 | CatalogSourceNamespace: source.Namespace, 37 | }, 38 | } 39 | for _, o := range opts { 40 | o(s) 41 | } 42 | return s 43 | } 44 | 45 | const defaultApproval = v1alpha1.ApprovalManual 46 | 47 | type ApprovalValue struct { 48 | v1alpha1.Approval 49 | } 50 | 51 | func (a *ApprovalValue) Set(str string) error { 52 | switch v := v1alpha1.Approval(str); v { 53 | case v1alpha1.ApprovalAutomatic, v1alpha1.ApprovalManual: 54 | a.Approval = v 55 | return nil 56 | } 57 | return fmt.Errorf("invalid approval value %q", str) 58 | } 59 | 60 | func (a *ApprovalValue) String() string { 61 | if a.Approval == "" { 62 | a.Approval = defaultApproval 63 | } 64 | return string(a.Approval) 65 | } 66 | 67 | func (a ApprovalValue) Type() string { 68 | return "ApprovalValue" 69 | } 70 | -------------------------------------------------------------------------------- /internal/pkg/v1/action/action_suite_test.go: -------------------------------------------------------------------------------- 1 | package action_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | 8 | . "github.com/onsi/ginkgo" 9 | . "github.com/onsi/gomega" 10 | 11 | apimeta "k8s.io/apimachinery/pkg/api/meta" 12 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 13 | "k8s.io/apimachinery/pkg/types" 14 | "sigs.k8s.io/controller-runtime/pkg/client" 15 | "sigs.k8s.io/controller-runtime/pkg/client/fake" 16 | "sigs.k8s.io/controller-runtime/pkg/client/interceptor" 17 | 18 | olmv1 "github.com/operator-framework/operator-controller/api/v1" 19 | 20 | "github.com/operator-framework/kubectl-operator/pkg/action" 21 | ) 22 | 23 | const ( 24 | verbCreate = "create" 25 | ) 26 | 27 | func TestCommand(t *testing.T) { 28 | RegisterFailHandler(Fail) 29 | RunSpecs(t, "Internal v1 action Suite") 30 | } 31 | 32 | type fakeClient struct { 33 | // Expected errors for create/delete/get. 34 | createErr error 35 | deleteErr error 36 | getErr error 37 | 38 | // counters for number of create/delete/get calls seen. 39 | createCalled int 40 | deleteCalled int 41 | getCalled int 42 | 43 | // transformer functions for applying changes to an object 44 | // matching the objectKey prior to an operation of the 45 | // type `verb` (get/create/delete), where the operation is 46 | // not set to error fail with a corresponding error (getErr/createErr/deleteErr). 47 | transformers []objectTransformer 48 | client.Client 49 | } 50 | 51 | type objectTransformer struct { 52 | verb string 53 | objectKey client.ObjectKey 54 | transformFunc func(obj *client.Object) 55 | } 56 | 57 | func (c *fakeClient) Initialize() error { 58 | scheme, err := action.NewScheme() 59 | if err != nil { 60 | return err 61 | } 62 | clientBuilder := fake.NewClientBuilder().WithInterceptorFuncs(interceptor.Funcs{ 63 | Create: func(ctx context.Context, client client.WithWatch, obj client.Object, opts ...client.CreateOption) error { 64 | c.createCalled++ 65 | if c.createErr != nil { 66 | return c.createErr 67 | } 68 | objKey := types.NamespacedName{Name: obj.GetName(), Namespace: obj.GetNamespace()} 69 | for _, t := range c.transformers { 70 | if t.verb == verbCreate && objKey == t.objectKey && t.transformFunc != nil { 71 | t.transformFunc(&obj) 72 | } 73 | } 74 | // make sure to plumb request through to underlying client 75 | return client.Create(ctx, obj, opts...) 76 | }, 77 | Delete: func(ctx context.Context, client client.WithWatch, obj client.Object, opts ...client.DeleteOption) error { 78 | c.deleteCalled++ 79 | if c.deleteErr != nil { 80 | return c.deleteErr 81 | } 82 | return client.Delete(ctx, obj, opts...) 83 | }, 84 | Get: func(ctx context.Context, client client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { 85 | c.getCalled++ 86 | if c.getErr != nil { 87 | return c.getErr 88 | } 89 | return client.Get(ctx, key, obj, opts...) 90 | }, 91 | }).WithScheme(scheme) 92 | c.Client = clientBuilder.Build() 93 | return nil 94 | } 95 | 96 | func setupTestCatalogs(n int) []client.Object { 97 | var result []client.Object 98 | for i := 1; i <= n; i++ { 99 | result = append(result, newClusterCatalog(fmt.Sprintf("cat%d", i))) 100 | } 101 | 102 | return result 103 | } 104 | 105 | func newClusterCatalog(name string) *olmv1.ClusterCatalog { 106 | return &olmv1.ClusterCatalog{ 107 | ObjectMeta: metav1.ObjectMeta{Name: name}, 108 | } 109 | } 110 | 111 | type extensionOpt func(*olmv1.ClusterExtension) 112 | 113 | type catalogOpt func(*olmv1.ClusterCatalog) 114 | 115 | func withVersion(version string) extensionOpt { 116 | return func(ext *olmv1.ClusterExtension) { 117 | ext.Spec.Source.Catalog.Version = version 118 | } 119 | } 120 | 121 | func withSourceType(sourceType string) extensionOpt { 122 | return func(ext *olmv1.ClusterExtension) { 123 | ext.Spec.Source.SourceType = sourceType 124 | } 125 | } 126 | 127 | // nolint: unparam 128 | func withConstraintPolicy(policy string) extensionOpt { 129 | return func(ext *olmv1.ClusterExtension) { 130 | ext.Spec.Source.Catalog.UpgradeConstraintPolicy = olmv1.UpgradeConstraintPolicy(policy) 131 | } 132 | } 133 | 134 | func withChannels(channels ...string) extensionOpt { 135 | return func(ext *olmv1.ClusterExtension) { 136 | ext.Spec.Source.Catalog.Channels = channels 137 | } 138 | } 139 | 140 | func withLabels(labels map[string]string) extensionOpt { 141 | return func(ext *olmv1.ClusterExtension) { 142 | ext.SetLabels(labels) 143 | } 144 | } 145 | 146 | func withCatalogSourceType(sourceType olmv1.SourceType) catalogOpt { 147 | return func(catalog *olmv1.ClusterCatalog) { 148 | catalog.Spec.Source.Type = sourceType 149 | } 150 | } 151 | 152 | func withCatalogSourcePriority(priority *int32) catalogOpt { 153 | return func(catalog *olmv1.ClusterCatalog) { 154 | catalog.Spec.Priority = *priority 155 | } 156 | } 157 | 158 | func withCatalogPollInterval(pollInterval *int) catalogOpt { 159 | return func(catalog *olmv1.ClusterCatalog) { 160 | if catalog.Spec.Source.Image == nil { 161 | catalog.Spec.Source.Image = &olmv1.ImageSource{} 162 | } 163 | catalog.Spec.Source.Image.PollIntervalMinutes = pollInterval 164 | } 165 | } 166 | 167 | func withCatalogImageRef(ref string) catalogOpt { 168 | return func(catalog *olmv1.ClusterCatalog) { 169 | if catalog.Spec.Source.Image == nil { 170 | catalog.Spec.Source.Image = &olmv1.ImageSource{} 171 | } 172 | catalog.Spec.Source.Image.Ref = ref 173 | } 174 | } 175 | 176 | func withCatalogAvailabilityMode(mode olmv1.AvailabilityMode) catalogOpt { 177 | return func(catalog *olmv1.ClusterCatalog) { 178 | catalog.Spec.AvailabilityMode = mode 179 | } 180 | } 181 | 182 | func withCatalogLabels(labels map[string]string) catalogOpt { 183 | return func(catalog *olmv1.ClusterCatalog) { 184 | catalog.Labels = labels 185 | } 186 | } 187 | 188 | func buildExtension(packageName string, opts ...extensionOpt) *olmv1.ClusterExtension { 189 | ext := &olmv1.ClusterExtension{ 190 | Spec: olmv1.ClusterExtensionSpec{ 191 | Source: olmv1.SourceConfig{ 192 | Catalog: &olmv1.CatalogFilter{PackageName: packageName}, 193 | }, 194 | }, 195 | } 196 | ext.SetName(packageName) 197 | for _, opt := range opts { 198 | opt(ext) 199 | } 200 | 201 | return ext 202 | } 203 | 204 | func updateExtensionConditionStatus(name string, cl client.Client, typ string, status metav1.ConditionStatus) error { 205 | var ext olmv1.ClusterExtension 206 | key := types.NamespacedName{Name: name} 207 | 208 | if err := cl.Get(context.TODO(), key, &ext); err != nil { 209 | return err 210 | } 211 | 212 | apimeta.SetStatusCondition(&ext.Status.Conditions, metav1.Condition{ 213 | Type: typ, 214 | Status: status, 215 | ObservedGeneration: ext.GetGeneration(), 216 | }) 217 | 218 | return cl.Update(context.TODO(), &ext) 219 | } 220 | 221 | func buildCatalog(catalogName string, opts ...catalogOpt) *olmv1.ClusterCatalog { 222 | catalog := &olmv1.ClusterCatalog{ 223 | ObjectMeta: metav1.ObjectMeta{ 224 | Name: catalogName, 225 | }, 226 | Spec: olmv1.ClusterCatalogSpec{ 227 | Source: olmv1.CatalogSource{ 228 | Type: olmv1.SourceTypeImage, 229 | }, 230 | }, 231 | } 232 | catalog.SetName(catalogName) 233 | for _, opt := range opts { 234 | opt(catalog) 235 | } 236 | 237 | return catalog 238 | } 239 | -------------------------------------------------------------------------------- /internal/pkg/v1/action/catalog_create.go: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 8 | 9 | olmv1 "github.com/operator-framework/operator-controller/api/v1" 10 | 11 | "github.com/operator-framework/kubectl-operator/pkg/action" 12 | ) 13 | 14 | type CatalogCreate struct { 15 | config *action.Configuration 16 | CatalogName string 17 | ImageSourceRef string 18 | 19 | Priority int32 20 | PollIntervalMinutes int 21 | Labels map[string]string 22 | Available bool 23 | CleanupTimeout time.Duration 24 | 25 | Logf func(string, ...interface{}) 26 | } 27 | 28 | func NewCatalogCreate(config *action.Configuration) *CatalogCreate { 29 | return &CatalogCreate{ 30 | config: config, 31 | Logf: func(string, ...interface{}) {}, 32 | } 33 | } 34 | 35 | func (i *CatalogCreate) Run(ctx context.Context) error { 36 | catalog := i.buildCatalog() 37 | if err := i.config.Client.Create(ctx, &catalog); err != nil { 38 | return err 39 | } 40 | 41 | var err error 42 | if i.Available { 43 | err = waitUntilCatalogStatusCondition(ctx, i.config.Client, &catalog, olmv1.TypeServing, metav1.ConditionTrue) 44 | } else { 45 | err = waitUntilCatalogStatusCondition(ctx, i.config.Client, &catalog, olmv1.TypeServing, metav1.ConditionFalse) 46 | } 47 | 48 | if err != nil { 49 | if cleanupErr := deleteWithTimeout(i.config.Client, &catalog, i.CleanupTimeout); cleanupErr != nil { 50 | i.Logf("cleaning up failed catalog: %v", cleanupErr) 51 | } 52 | return err 53 | } 54 | 55 | return nil 56 | } 57 | 58 | func (i *CatalogCreate) buildCatalog() olmv1.ClusterCatalog { 59 | catalog := olmv1.ClusterCatalog{ 60 | ObjectMeta: metav1.ObjectMeta{ 61 | Name: i.CatalogName, 62 | Labels: i.Labels, 63 | }, 64 | Spec: olmv1.ClusterCatalogSpec{ 65 | Source: olmv1.CatalogSource{ 66 | Type: olmv1.SourceTypeImage, 67 | Image: &olmv1.ImageSource{ 68 | Ref: i.ImageSourceRef, 69 | PollIntervalMinutes: &i.PollIntervalMinutes, 70 | }, 71 | }, 72 | Priority: i.Priority, 73 | AvailabilityMode: olmv1.AvailabilityModeAvailable, 74 | }, 75 | } 76 | if !i.Available { 77 | catalog.Spec.AvailabilityMode = olmv1.AvailabilityModeUnavailable 78 | } 79 | 80 | return catalog 81 | } 82 | -------------------------------------------------------------------------------- /internal/pkg/v1/action/catalog_create_test.go: -------------------------------------------------------------------------------- 1 | package action_test 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | . "github.com/onsi/ginkgo" 8 | . "github.com/onsi/gomega" 9 | 10 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 11 | "k8s.io/apimachinery/pkg/types" 12 | "sigs.k8s.io/controller-runtime/pkg/client" 13 | 14 | olmv1 "github.com/operator-framework/operator-controller/api/v1" 15 | 16 | internalaction "github.com/operator-framework/kubectl-operator/internal/pkg/v1/action" 17 | "github.com/operator-framework/kubectl-operator/pkg/action" 18 | ) 19 | 20 | var _ = Describe("CatalogCreate", func() { 21 | catalogName := "testcatalog" 22 | pollInterval := 20 23 | expectedCatalog := olmv1.ClusterCatalog{ 24 | ObjectMeta: metav1.ObjectMeta{ 25 | Name: catalogName, 26 | Labels: map[string]string{"a": "b"}, 27 | }, 28 | Spec: olmv1.ClusterCatalogSpec{ 29 | Source: olmv1.CatalogSource{ 30 | Type: olmv1.SourceTypeImage, 31 | Image: &olmv1.ImageSource{ 32 | Ref: "testcatalog:latest", 33 | PollIntervalMinutes: &pollInterval, 34 | }, 35 | }, 36 | Priority: 77, 37 | AvailabilityMode: olmv1.AvailabilityModeAvailable, 38 | }, 39 | } 40 | 41 | It("fails creating catalog", func() { 42 | expectedErr := errors.New("create failed") 43 | testClient := fakeClient{createErr: expectedErr} 44 | Expect(testClient.Initialize()).To(Succeed()) 45 | 46 | creator := internalaction.NewCatalogCreate(&action.Configuration{Client: testClient}) 47 | creator.Available = true 48 | creator.CatalogName = expectedCatalog.Name 49 | creator.ImageSourceRef = expectedCatalog.Spec.Source.Image.Ref 50 | creator.Priority = expectedCatalog.Spec.Priority 51 | creator.Labels = expectedCatalog.Labels 52 | creator.PollIntervalMinutes = *expectedCatalog.Spec.Source.Image.PollIntervalMinutes 53 | err := creator.Run(context.TODO()) 54 | 55 | Expect(err).NotTo(BeNil()) 56 | Expect(err).To(MatchError(expectedErr)) 57 | Expect(testClient.createCalled).To(Equal(1)) 58 | }) 59 | 60 | It("fails waiting for created catalog status, successfully cleans up", func() { 61 | expectedErr := errors.New("get failed") 62 | testClient := fakeClient{getErr: expectedErr} 63 | Expect(testClient.Initialize()).To(Succeed()) 64 | 65 | creator := internalaction.NewCatalogCreate(&action.Configuration{Client: testClient}) 66 | // fakeClient requires at least the catalogName to be set to run 67 | creator.CatalogName = expectedCatalog.Name 68 | err := creator.Run(context.TODO()) 69 | 70 | Expect(err).NotTo(BeNil()) 71 | Expect(err).To(MatchError(expectedErr)) 72 | Expect(testClient.createCalled).To(Equal(1)) 73 | Expect(testClient.getCalled).To(Equal(1)) 74 | Expect(testClient.deleteCalled).To(Equal(1)) 75 | }) 76 | 77 | It("fails waiting for created catalog status, fails clean up", func() { 78 | getErr := errors.New("get failed") 79 | deleteErr := errors.New("delete failed") 80 | testClient := fakeClient{deleteErr: deleteErr, getErr: getErr} 81 | Expect(testClient.Initialize()).To(Succeed()) 82 | 83 | creator := internalaction.NewCatalogCreate(&action.Configuration{Client: testClient}) 84 | // fakeClient requires at least the catalogName to be set to run 85 | creator.CatalogName = expectedCatalog.Name 86 | err := creator.Run(context.TODO()) 87 | 88 | Expect(err).NotTo(BeNil()) 89 | Expect(err).To(MatchError(getErr)) 90 | Expect(testClient.createCalled).To(Equal(1)) 91 | Expect(testClient.getCalled).To(Equal(1)) 92 | Expect(testClient.deleteCalled).To(Equal(1)) 93 | }) 94 | It("succeeds creating catalog", func() { 95 | testClient := fakeClient{ 96 | transformers: []objectTransformer{ 97 | { 98 | verb: verbCreate, 99 | objectKey: types.NamespacedName{Name: catalogName}, 100 | transformFunc: func(obj *client.Object) { 101 | if obj == nil { 102 | return 103 | } 104 | catalogObj, ok := (*obj).(*olmv1.ClusterCatalog) 105 | if !ok { 106 | return 107 | } 108 | catalogObj.Status.Conditions = []metav1.Condition{{Type: olmv1.TypeServing, Status: metav1.ConditionTrue}} 109 | }, 110 | }, 111 | }, 112 | } 113 | Expect(testClient.Initialize()).To(Succeed()) 114 | 115 | creator := internalaction.NewCatalogCreate(&action.Configuration{Client: testClient}) 116 | creator.Available = true 117 | creator.CatalogName = expectedCatalog.Name 118 | creator.ImageSourceRef = expectedCatalog.Spec.Source.Image.Ref 119 | creator.Priority = expectedCatalog.Spec.Priority 120 | creator.Labels = expectedCatalog.Labels 121 | creator.PollIntervalMinutes = *expectedCatalog.Spec.Source.Image.PollIntervalMinutes 122 | Expect(creator.Run(context.TODO())).To(Succeed()) 123 | 124 | Expect(testClient.createCalled).To(Equal(1)) 125 | 126 | actualCatalog := &olmv1.ClusterCatalog{TypeMeta: metav1.TypeMeta{Kind: "ClusterCatalog", APIVersion: "olm.operatorframework.io/v1"}} 127 | Expect(testClient.Client.Get(context.TODO(), types.NamespacedName{Name: catalogName}, actualCatalog)).To(Succeed()) 128 | validateCreateCatalog(actualCatalog, &expectedCatalog) 129 | }) 130 | }) 131 | 132 | func validateCreateCatalog(actual, expected *olmv1.ClusterCatalog) { 133 | Expect(actual.Spec.Source.Image.Ref).To(Equal(expected.Spec.Source.Image.Ref)) 134 | Expect(actual.Spec.Source.Image.PollIntervalMinutes).To(Equal(expected.Spec.Source.Image.PollIntervalMinutes)) 135 | Expect(actual.Spec.AvailabilityMode).To(Equal(expected.Spec.AvailabilityMode)) 136 | Expect(actual.Labels).To(HaveLen(len(expected.Labels))) 137 | for k, v := range expected.Labels { 138 | Expect(actual.Labels).To(HaveKeyWithValue(k, v)) 139 | } 140 | Expect(actual.Spec.Priority).To(Equal(expected.Spec.Priority)) 141 | } 142 | -------------------------------------------------------------------------------- /internal/pkg/v1/action/catalog_delete.go: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | 8 | olmv1 "github.com/operator-framework/operator-controller/api/v1" 9 | 10 | "github.com/operator-framework/kubectl-operator/pkg/action" 11 | ) 12 | 13 | type CatalogDelete struct { 14 | config *action.Configuration 15 | CatalogName string 16 | DeleteAll bool 17 | 18 | Logf func(string, ...interface{}) 19 | } 20 | 21 | func NewCatalogDelete(cfg *action.Configuration) *CatalogDelete { 22 | return &CatalogDelete{ 23 | config: cfg, 24 | Logf: func(string, ...interface{}) {}, 25 | } 26 | } 27 | 28 | func (cd *CatalogDelete) Run(ctx context.Context) ([]string, error) { 29 | // validate 30 | if cd.DeleteAll && cd.CatalogName != "" { 31 | return nil, ErrNameAndSelector 32 | } 33 | 34 | // delete single, specified catalog 35 | if !cd.DeleteAll { 36 | return nil, cd.deleteCatalog(ctx, cd.CatalogName) 37 | } 38 | 39 | // delete all existing catalogs 40 | var catatalogList olmv1.ClusterCatalogList 41 | if err := cd.config.Client.List(ctx, &catatalogList); err != nil { 42 | return nil, err 43 | } 44 | if len(catatalogList.Items) == 0 { 45 | return nil, ErrNoResourcesFound 46 | } 47 | 48 | errs := make([]error, 0, len(catatalogList.Items)) 49 | names := make([]string, 0, len(catatalogList.Items)) 50 | for _, catalog := range catatalogList.Items { 51 | names = append(names, catalog.Name) 52 | if err := cd.deleteCatalog(ctx, catalog.Name); err != nil { 53 | errs = append(errs, fmt.Errorf("failed deleting catalog %q: %w", catalog.Name, err)) 54 | } 55 | } 56 | 57 | return names, errors.Join(errs...) 58 | } 59 | 60 | func (cd *CatalogDelete) deleteCatalog(ctx context.Context, name string) error { 61 | op := &olmv1.ClusterCatalog{} 62 | op.SetName(name) 63 | 64 | if err := cd.config.Client.Delete(ctx, op); err != nil { 65 | return err 66 | } 67 | 68 | return waitForDeletion(ctx, cd.config.Client, op) 69 | } 70 | -------------------------------------------------------------------------------- /internal/pkg/v1/action/catalog_delete_test.go: -------------------------------------------------------------------------------- 1 | package action_test 2 | 3 | import ( 4 | "context" 5 | "slices" 6 | 7 | . "github.com/onsi/ginkgo" 8 | . "github.com/onsi/gomega" 9 | 10 | "sigs.k8s.io/controller-runtime/pkg/client" 11 | "sigs.k8s.io/controller-runtime/pkg/client/fake" 12 | 13 | olmv1 "github.com/operator-framework/operator-controller/api/v1" 14 | 15 | internalaction "github.com/operator-framework/kubectl-operator/internal/pkg/v1/action" 16 | "github.com/operator-framework/kubectl-operator/pkg/action" 17 | ) 18 | 19 | var _ = Describe("CatalogDelete", func() { 20 | setupEnv := func(catalogs ...client.Object) action.Configuration { 21 | var cfg action.Configuration 22 | 23 | sch, err := action.NewScheme() 24 | Expect(err).To(BeNil()) 25 | 26 | cl := fake.NewClientBuilder(). 27 | WithObjects(catalogs...). 28 | WithScheme(sch). 29 | Build() 30 | cfg.Scheme = sch 31 | cfg.Client = cl 32 | 33 | return cfg 34 | } 35 | 36 | It("fails because of both resource name and --all specifier being present", func() { 37 | cfg := setupEnv(setupTestCatalogs(2)...) 38 | 39 | deleter := internalaction.NewCatalogDelete(&cfg) 40 | deleter.CatalogName = "name" 41 | deleter.DeleteAll = true 42 | catNames, err := deleter.Run(context.TODO()) 43 | Expect(err).NotTo(BeNil()) 44 | Expect(catNames).To(BeEmpty()) 45 | 46 | validateExistingCatalogs(cfg.Client, []string{"cat1", "cat2"}) 47 | }) 48 | 49 | It("fails deleting a non-existing catalog", func() { 50 | cfg := setupEnv(setupTestCatalogs(2)...) 51 | 52 | deleter := internalaction.NewCatalogDelete(&cfg) 53 | deleter.CatalogName = "does-not-exist" 54 | catNames, err := deleter.Run(context.TODO()) 55 | Expect(err).NotTo(BeNil()) 56 | Expect(catNames).To(BeEmpty()) 57 | 58 | validateExistingCatalogs(cfg.Client, []string{"cat1", "cat2"}) 59 | }) 60 | 61 | It("successfully deletes an existing catalog", func() { 62 | cfg := setupEnv(setupTestCatalogs(3)...) 63 | 64 | deleter := internalaction.NewCatalogDelete(&cfg) 65 | deleter.CatalogName = "cat2" 66 | catNames, err := deleter.Run(context.TODO()) 67 | Expect(err).To(BeNil()) 68 | Expect(catNames).To(BeEmpty()) 69 | 70 | validateExistingCatalogs(cfg.Client, []string{"cat1", "cat3"}) 71 | }) 72 | 73 | It("fails deleting catalogs because there are none", func() { 74 | cfg := setupEnv() 75 | 76 | deleter := internalaction.NewCatalogDelete(&cfg) 77 | deleter.DeleteAll = true 78 | catNames, err := deleter.Run(context.TODO()) 79 | Expect(err).NotTo(BeNil()) 80 | Expect(catNames).To(BeEmpty()) 81 | 82 | validateExistingCatalogs(cfg.Client, []string{}) 83 | }) 84 | 85 | It("successfully deletes all catalogs", func() { 86 | cfg := setupEnv(setupTestCatalogs(3)...) 87 | 88 | deleter := internalaction.NewCatalogDelete(&cfg) 89 | deleter.DeleteAll = true 90 | catNames, err := deleter.Run(context.TODO()) 91 | Expect(err).To(BeNil()) 92 | Expect(catNames).To(ContainElements([]string{"cat1", "cat2", "cat3"})) 93 | 94 | validateExistingCatalogs(cfg.Client, []string{}) 95 | }) 96 | }) 97 | 98 | func validateExistingCatalogs(c client.Client, wantedNames []string) { 99 | var catalogsList olmv1.ClusterCatalogList 100 | err := c.List(context.TODO(), &catalogsList) 101 | Expect(err).To(BeNil()) 102 | 103 | catalogs := catalogsList.Items 104 | Expect(catalogs).To(HaveLen(len(wantedNames))) 105 | for _, wantedName := range wantedNames { 106 | Expect(slices.ContainsFunc(catalogs, func(cat olmv1.ClusterCatalog) bool { 107 | return cat.Name == wantedName 108 | })).To(BeTrue()) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /internal/pkg/v1/action/catalog_installed_get.go: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | import ( 4 | "context" 5 | 6 | "k8s.io/apimachinery/pkg/types" 7 | "sigs.k8s.io/controller-runtime/pkg/client" 8 | 9 | olmv1 "github.com/operator-framework/operator-controller/api/v1" 10 | 11 | "github.com/operator-framework/kubectl-operator/pkg/action" 12 | ) 13 | 14 | type CatalogInstalledGet struct { 15 | config *action.Configuration 16 | CatalogName string 17 | 18 | Logf func(string, ...interface{}) 19 | } 20 | 21 | func NewCatalogInstalledGet(cfg *action.Configuration) *CatalogInstalledGet { 22 | return &CatalogInstalledGet{ 23 | config: cfg, 24 | Logf: func(string, ...interface{}) {}, 25 | } 26 | } 27 | 28 | func (i *CatalogInstalledGet) Run(ctx context.Context) ([]olmv1.ClusterCatalog, error) { 29 | // get 30 | if i.CatalogName != "" { 31 | var result olmv1.ClusterCatalog 32 | 33 | opKey := types.NamespacedName{Name: i.CatalogName} 34 | err := i.config.Client.Get(ctx, opKey, &result) 35 | if err != nil { 36 | return nil, err 37 | } 38 | 39 | return []olmv1.ClusterCatalog{result}, nil 40 | } 41 | 42 | // list 43 | var result olmv1.ClusterCatalogList 44 | err := i.config.Client.List(ctx, &result, &client.ListOptions{}) 45 | 46 | return result.Items, err 47 | } 48 | -------------------------------------------------------------------------------- /internal/pkg/v1/action/catalog_installed_get_test.go: -------------------------------------------------------------------------------- 1 | package action_test 2 | 3 | import ( 4 | "context" 5 | "slices" 6 | 7 | . "github.com/onsi/ginkgo" 8 | . "github.com/onsi/gomega" 9 | 10 | "sigs.k8s.io/controller-runtime/pkg/client" 11 | "sigs.k8s.io/controller-runtime/pkg/client/fake" 12 | 13 | olmv1 "github.com/operator-framework/operator-controller/api/v1" 14 | 15 | internalaction "github.com/operator-framework/kubectl-operator/internal/pkg/v1/action" 16 | "github.com/operator-framework/kubectl-operator/pkg/action" 17 | ) 18 | 19 | var _ = Describe("CatalogInstalledGet", func() { 20 | setupEnv := func(catalogs ...client.Object) action.Configuration { 21 | var cfg action.Configuration 22 | 23 | sch, err := action.NewScheme() 24 | Expect(err).To(BeNil()) 25 | 26 | cl := fake.NewClientBuilder(). 27 | WithObjects(catalogs...). 28 | WithScheme(sch). 29 | Build() 30 | cfg.Scheme = sch 31 | cfg.Client = cl 32 | 33 | return cfg 34 | } 35 | 36 | It("lists all installed catalogs", func() { 37 | cfg := setupEnv(setupTestCatalogs(3)...) 38 | 39 | getter := internalaction.NewCatalogInstalledGet(&cfg) 40 | catalogs, err := getter.Run(context.TODO()) 41 | Expect(err).To(BeNil()) 42 | Expect(catalogs).NotTo(BeEmpty()) 43 | Expect(catalogs).To(HaveLen(3)) 44 | 45 | for _, testCatalogName := range []string{"cat1", "cat2", "cat3"} { 46 | Expect(slices.ContainsFunc(catalogs, func(cat olmv1.ClusterCatalog) bool { 47 | return cat.Name == testCatalogName 48 | })).To(BeTrue()) 49 | } 50 | }) 51 | 52 | It("returns empty list in case no catalogs were found", func() { 53 | cfg := setupEnv() 54 | 55 | getter := internalaction.NewCatalogInstalledGet(&cfg) 56 | catalogs, err := getter.Run(context.TODO()) 57 | Expect(err).To(BeNil()) 58 | Expect(catalogs).To(BeEmpty()) 59 | }) 60 | 61 | It("gets an installed catalog", func() { 62 | cfg := setupEnv(setupTestCatalogs(3)...) 63 | 64 | getter := internalaction.NewCatalogInstalledGet(&cfg) 65 | getter.CatalogName = "cat2" 66 | catalogs, err := getter.Run(context.TODO()) 67 | Expect(err).To(BeNil()) 68 | Expect(catalogs).NotTo(BeEmpty()) 69 | Expect(catalogs).To(HaveLen(1)) 70 | Expect(catalogs[0].Name).To(Equal("cat2")) 71 | }) 72 | 73 | It("returns an empty list when an installed catalog was not found", func() { 74 | cfg := setupEnv() 75 | 76 | getter := internalaction.NewCatalogInstalledGet(&cfg) 77 | getter.CatalogName = "cat2" 78 | catalogs, err := getter.Run(context.TODO()) 79 | Expect(err).NotTo(BeNil()) 80 | Expect(catalogs).To(BeEmpty()) 81 | }) 82 | }) 83 | -------------------------------------------------------------------------------- /internal/pkg/v1/action/catalog_update.go: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "regexp" 7 | 8 | "k8s.io/apimachinery/pkg/types" 9 | 10 | olmv1 "github.com/operator-framework/operator-controller/api/v1" 11 | 12 | "github.com/operator-framework/kubectl-operator/pkg/action" 13 | ) 14 | 15 | type CatalogUpdate struct { 16 | config *action.Configuration 17 | CatalogName string 18 | 19 | Priority *int32 20 | PollIntervalMinutes *int 21 | Labels map[string]string 22 | AvailabilityMode string 23 | ImageRef string 24 | IgnoreUnset bool 25 | 26 | Logf func(string, ...interface{}) 27 | } 28 | 29 | func NewCatalogUpdate(config *action.Configuration) *CatalogUpdate { 30 | return &CatalogUpdate{ 31 | config: config, 32 | Logf: func(string, ...interface{}) {}, 33 | } 34 | } 35 | 36 | func (cu *CatalogUpdate) Run(ctx context.Context) (*olmv1.ClusterCatalog, error) { 37 | var catalog olmv1.ClusterCatalog 38 | var err error 39 | 40 | cuKey := types.NamespacedName{ 41 | Name: cu.CatalogName, 42 | Namespace: cu.config.Namespace, 43 | } 44 | if err = cu.config.Client.Get(ctx, cuKey, &catalog); err != nil { 45 | return nil, err 46 | } 47 | 48 | if catalog.Spec.Source.Type != olmv1.SourceTypeImage { 49 | return nil, fmt.Errorf("unrecognized source type: %q", catalog.Spec.Source.Type) 50 | } 51 | 52 | if cu.ImageRef != "" && !isValidImageRef(cu.ImageRef) { 53 | return nil, fmt.Errorf("invalid image reference: %q, it must be a valid image reference format", cu.ImageRef) 54 | } 55 | 56 | cu.setDefaults(&catalog) 57 | 58 | cu.setUpdatedCatalog(&catalog) 59 | if err := cu.config.Client.Update(ctx, &catalog); err != nil { 60 | return nil, err 61 | } 62 | 63 | cu.Logf("Updating catalog %q in namespace %q", cu.CatalogName, cu.config.Namespace) 64 | 65 | return &catalog, nil 66 | } 67 | 68 | func (cu *CatalogUpdate) setUpdatedCatalog(catalog *olmv1.ClusterCatalog) { 69 | existingLabels := catalog.GetLabels() 70 | if existingLabels == nil { 71 | existingLabels = make(map[string]string) 72 | } 73 | if cu.Labels != nil { 74 | for k, v := range cu.Labels { 75 | if v == "" { 76 | delete(existingLabels, k) 77 | } else { 78 | existingLabels[k] = v 79 | } 80 | } 81 | catalog.SetLabels(existingLabels) 82 | } 83 | 84 | if cu.Priority != nil { 85 | catalog.Spec.Priority = *cu.Priority 86 | } 87 | 88 | if catalog.Spec.Source.Image == nil { 89 | catalog.Spec.Source.Image = &olmv1.ImageSource{} 90 | } 91 | 92 | if cu.PollIntervalMinutes != nil { 93 | if *cu.PollIntervalMinutes == 0 || *cu.PollIntervalMinutes == -1 { 94 | catalog.Spec.Source.Image.PollIntervalMinutes = nil 95 | } else { 96 | catalog.Spec.Source.Image.PollIntervalMinutes = cu.PollIntervalMinutes 97 | } 98 | } 99 | 100 | if cu.ImageRef != "" { 101 | catalog.Spec.Source.Image.Ref = cu.ImageRef 102 | } 103 | 104 | if cu.AvailabilityMode != "" { 105 | catalog.Spec.AvailabilityMode = olmv1.AvailabilityMode(cu.AvailabilityMode) 106 | } 107 | } 108 | 109 | func (cu *CatalogUpdate) setDefaults(catalog *olmv1.ClusterCatalog) { 110 | if !cu.IgnoreUnset { 111 | return 112 | } 113 | 114 | catalogSrc := catalog.Spec.Source 115 | 116 | if cu.Priority == nil { 117 | cu.Priority = &catalog.Spec.Priority 118 | } 119 | 120 | if cu.PollIntervalMinutes == nil && catalogSrc.Image != nil && catalogSrc.Image.PollIntervalMinutes != nil { 121 | cu.PollIntervalMinutes = catalogSrc.Image.PollIntervalMinutes 122 | } 123 | 124 | if cu.ImageRef == "" && catalogSrc.Image != nil { 125 | cu.ImageRef = catalogSrc.Image.Ref 126 | } 127 | if cu.AvailabilityMode == "" { 128 | cu.AvailabilityMode = string(catalog.Spec.AvailabilityMode) 129 | } 130 | if len(cu.Labels) == 0 { 131 | cu.Labels = catalog.Labels 132 | } 133 | } 134 | 135 | func isValidImageRef(imageRef string) bool { 136 | var imageRefRegex = regexp.MustCompile(`^([a-z0-9]+(\.[a-z0-9]+)*(:[0-9]+)?/)?[a-z0-9-_]+(/[a-z0-9-_]+)*(:[a-zA-Z0-9_\.-]+)?(@sha256:[a-fA-F0-9]{64})?$`) 137 | 138 | return imageRefRegex.MatchString(imageRef) 139 | } 140 | -------------------------------------------------------------------------------- /internal/pkg/v1/action/catalog_update_test.go: -------------------------------------------------------------------------------- 1 | package action_test 2 | 3 | import ( 4 | "context" 5 | 6 | . "github.com/onsi/ginkgo" 7 | . "github.com/onsi/gomega" 8 | 9 | "sigs.k8s.io/controller-runtime/pkg/client" 10 | "sigs.k8s.io/controller-runtime/pkg/client/fake" 11 | 12 | olmv1 "github.com/operator-framework/operator-controller/api/v1" 13 | 14 | internalaction "github.com/operator-framework/kubectl-operator/internal/pkg/v1/action" 15 | "github.com/operator-framework/kubectl-operator/pkg/action" 16 | ) 17 | 18 | var _ = Describe("CatalogUpdate", func() { 19 | setupEnv := func(catalogs ...client.Object) action.Configuration { 20 | var cfg action.Configuration 21 | 22 | sch, err := action.NewScheme() 23 | Expect(err).To(BeNil()) 24 | 25 | cl := fake.NewClientBuilder(). 26 | WithObjects(catalogs...). 27 | WithScheme(sch). 28 | Build() 29 | cfg.Scheme = sch 30 | cfg.Client = cl 31 | 32 | return cfg 33 | } 34 | 35 | It("fails finding existing catalog", func() { 36 | cfg := setupEnv() 37 | 38 | updater := internalaction.NewCatalogUpdate(&cfg) 39 | updater.CatalogName = "does-not-exist" 40 | cat, err := updater.Run(context.TODO()) 41 | 42 | Expect(err).NotTo(BeNil()) 43 | Expect(err.Error()).To(ContainSubstring("not found")) 44 | Expect(cat).To(BeNil()) 45 | }) 46 | 47 | It("fails to handle catalog with unknown source type", func() { 48 | cfg := setupEnv(buildCatalog("test", withCatalogSourceType("invalid-type"))) 49 | 50 | updater := internalaction.NewCatalogUpdate(&cfg) 51 | updater.CatalogName = "test" 52 | _, err := updater.Run(context.TODO()) 53 | 54 | Expect(err).NotTo(BeNil()) 55 | Expect(err.Error()).To(ContainSubstring("unrecognized source type")) 56 | }) 57 | 58 | It("successfully updates catalog", func() { 59 | testCatalog := buildCatalog( 60 | "testCatalog", 61 | withCatalogSourceType(olmv1.SourceTypeImage), 62 | withCatalogPollInterval(pointerToInt(5)), 63 | withCatalogSourcePriority(pointerToInt32(1)), 64 | withCatalogImageRef("quay.io/myrepo/myimage"), 65 | withCatalogAvailabilityMode(olmv1.AvailabilityModeAvailable), 66 | withCatalogLabels(map[string]string{"foo": "bar"}), 67 | ) 68 | cfg := setupEnv(testCatalog) 69 | 70 | updater := internalaction.NewCatalogUpdate(&cfg) 71 | updater.CatalogName = "testCatalog" 72 | updater.Priority = pointerToInt32(1) 73 | updater.Labels = map[string]string{"abc": "xyz"} 74 | updater.AvailabilityMode = string(olmv1.AvailabilityModeAvailable) 75 | updater.PollIntervalMinutes = pointerToInt(5) 76 | catalog, err := updater.Run(context.TODO()) 77 | 78 | Expect(err).To(BeNil()) 79 | Expect(testCatalog).NotTo(BeNil()) 80 | Expect(catalog.Labels).To(HaveKeyWithValue("foo", "bar")) //existing 81 | Expect(catalog.Labels).To(HaveKeyWithValue("abc", "xyz")) //newly added 82 | Expect(catalog.Spec.Priority).To(Equal(*updater.Priority)) 83 | Expect(catalog.Spec.Source.Image.PollIntervalMinutes).ToNot(BeNil()) 84 | Expect(*catalog.Spec.Source.Image.PollIntervalMinutes).To(Equal(*updater.PollIntervalMinutes)) 85 | Expect(catalog.Spec.AvailabilityMode).To(Equal(olmv1.AvailabilityMode(updater.AvailabilityMode))) 86 | }) 87 | 88 | It("unsets the poll interval field when set to 0", func() { 89 | testCatalog := buildCatalog( 90 | "test", 91 | withCatalogSourceType(olmv1.SourceTypeImage), 92 | withCatalogPollInterval(pointerToInt(7)), 93 | withCatalogImageRef("quay.io/myrepo/myimage"), 94 | ) 95 | cfg := setupEnv(testCatalog) 96 | 97 | updater := internalaction.NewCatalogUpdate(&cfg) 98 | updater.CatalogName = "test" 99 | updater.PollIntervalMinutes = pointerToInt(-1) 100 | catalog, err := updater.Run(context.TODO()) 101 | 102 | Expect(err).NotTo(HaveOccurred()) 103 | Expect(catalog.Spec.Source.Image.PollIntervalMinutes).To(BeNil()) 104 | }) 105 | 106 | It("unsets the poll interval field when set to 0", func() { 107 | testCatalog := buildCatalog( 108 | "test", 109 | withCatalogSourceType(olmv1.SourceTypeImage), 110 | withCatalogPollInterval(pointerToInt(10)), 111 | withCatalogImageRef("quay.io/myrepo/myimage"), 112 | ) 113 | cfg := setupEnv(testCatalog) 114 | 115 | updater := internalaction.NewCatalogUpdate(&cfg) 116 | updater.CatalogName = "test" 117 | updater.PollIntervalMinutes = pointerToInt(0) 118 | 119 | catalog, err := updater.Run(context.TODO()) 120 | 121 | Expect(err).NotTo(HaveOccurred()) 122 | Expect(catalog.Spec.Source.Image.PollIntervalMinutes).To(BeNil()) 123 | }) 124 | 125 | It("succeessfully updates catalog with a valid image reference", func() { 126 | testCatalog := buildCatalog( 127 | "test", 128 | withCatalogSourceType(olmv1.SourceTypeImage), 129 | withCatalogImageRef("quay.io/myrepo/myimage"), 130 | withCatalogPollInterval(pointerToInt(10)), 131 | withCatalogSourcePriority(pointerToInt32(5)), 132 | withCatalogAvailabilityMode(olmv1.AvailabilityModeAvailable), 133 | withCatalogLabels(map[string]string{"foo": "bar"}), 134 | ) 135 | cfg := setupEnv(testCatalog) 136 | 137 | updater := internalaction.NewCatalogUpdate(&cfg) 138 | updater.CatalogName = "test" 139 | updater.ImageRef = "quay.io/myrepo/mynewimage" 140 | catalog, err := updater.Run(context.TODO()) 141 | 142 | Expect(err).NotTo(HaveOccurred()) 143 | Expect(catalog.Spec.Source.Image.Ref).To(Equal(updater.ImageRef)) 144 | }) 145 | 146 | It("fails catalog update with an invalid image reference", func() { 147 | testCatalog := buildCatalog( 148 | "test", 149 | withCatalogSourceType(olmv1.SourceTypeImage), 150 | withCatalogImageRef("quay.io/valid/image"), 151 | ) 152 | cfg := setupEnv(testCatalog) 153 | 154 | updater := internalaction.NewCatalogUpdate(&cfg) 155 | updater.CatalogName = "test" 156 | updater.ImageRef = "invalid//image!!" 157 | 158 | _, err := updater.Run(context.TODO()) 159 | Expect(err).To(HaveOccurred()) 160 | Expect(err.Error()).To(ContainSubstring("invalid image reference")) 161 | }) 162 | 163 | It("removes labels with empty values and merges the rest", func() { 164 | initial := map[string]string{"foo": "bar", "remove": "yes"} 165 | testCatalog := buildCatalog( 166 | "test", 167 | withCatalogSourceType(olmv1.SourceTypeImage), 168 | withCatalogLabels(initial), 169 | ) 170 | cfg := setupEnv(testCatalog) 171 | 172 | updater := internalaction.NewCatalogUpdate(&cfg) 173 | updater.CatalogName = "test" 174 | updater.Labels = map[string]string{ 175 | "remove": "", 176 | "new": "label", 177 | } 178 | catalog, err := updater.Run(context.TODO()) 179 | Expect(err).NotTo(HaveOccurred()) 180 | 181 | Expect(catalog.Labels).To(Equal(map[string]string{ 182 | "foo": "bar", 183 | "new": "label", 184 | })) 185 | }) 186 | 187 | It("preserves labels when Labels field is nil", func() { 188 | testCatalog := buildCatalog( 189 | "test", 190 | withCatalogSourceType(olmv1.SourceTypeImage), 191 | withCatalogLabels(map[string]string{"retain": "this"}), 192 | ) 193 | cfg := setupEnv(testCatalog) 194 | 195 | updater := internalaction.NewCatalogUpdate(&cfg) 196 | updater.CatalogName = "test" 197 | updater.Labels = nil 198 | 199 | catalog, err := updater.Run(context.TODO()) 200 | Expect(err).NotTo(HaveOccurred()) 201 | Expect(catalog.Labels).To(Equal(map[string]string{"retain": "this"})) 202 | }) 203 | 204 | It("preserves priority and poll interval when ignoreUnset flag is true and flags not explicitly set", func() { 205 | testCatalog := buildCatalog( 206 | "test", 207 | withCatalogSourceType(olmv1.SourceTypeImage), 208 | withCatalogPollInterval(pointerToInt(10)), 209 | withCatalogSourcePriority(pointerToInt32(3)), 210 | withCatalogImageRef("quay.io/myrepo/image"), 211 | withCatalogLabels(map[string]string{"foo": "bar"}), 212 | ) 213 | 214 | cfg := setupEnv(testCatalog) 215 | 216 | updater := internalaction.NewCatalogUpdate(&cfg) 217 | updater.CatalogName = "test" 218 | updater.IgnoreUnset = true 219 | 220 | catalog, err := updater.Run(context.TODO()) 221 | Expect(err).NotTo(HaveOccurred()) 222 | Expect(catalog).NotTo(BeNil()) 223 | 224 | Expect(catalog.Spec.Priority).To(Equal(int32(3))) 225 | Expect(catalog.Spec.Source.Image).NotTo(BeNil()) 226 | Expect(catalog.Labels).To(Equal(map[string]string{"foo": "bar"})) 227 | Expect(catalog.Spec.Source.Image.PollIntervalMinutes).NotTo(BeNil()) 228 | Expect(*catalog.Spec.Source.Image.PollIntervalMinutes).To(Equal(10)) 229 | 230 | }) 231 | 232 | It("resets priority and poll interval when ignoreUnset is false and flags are nil", func() { 233 | testCatalog := buildCatalog( 234 | "test", 235 | withCatalogSourceType(olmv1.SourceTypeImage), 236 | withCatalogPollInterval(pointerToInt(10)), 237 | withCatalogSourcePriority(pointerToInt32(3)), 238 | withCatalogImageRef("quay.io/myrepo/image"), 239 | ) 240 | 241 | cfg := setupEnv(testCatalog) 242 | 243 | updater := internalaction.NewCatalogUpdate(&cfg) 244 | updater.CatalogName = "test" 245 | updater.IgnoreUnset = false 246 | updater.Priority = nil 247 | updater.PollIntervalMinutes = nil 248 | updater.ImageRef = "" 249 | updater.AvailabilityMode = "" 250 | 251 | catalog, err := updater.Run(context.TODO()) 252 | Expect(err).NotTo(HaveOccurred()) 253 | 254 | Expect(catalog.Spec.Priority).To(Equal(int32(3))) 255 | Expect(catalog.Spec.Source.Image.Ref).To(Equal("quay.io/myrepo/image")) 256 | Expect(string(catalog.Spec.AvailabilityMode)).To(BeEmpty()) 257 | Expect(catalog.Spec.Source.Image.PollIntervalMinutes).ToNot(BeNil()) 258 | Expect(*catalog.Spec.Source.Image.PollIntervalMinutes).To(Equal(10)) 259 | 260 | }) 261 | }) 262 | 263 | func pointerToInt32(i int32) *int32 { 264 | return &i 265 | } 266 | 267 | func pointerToInt(i int) *int { 268 | return &i 269 | } 270 | -------------------------------------------------------------------------------- /internal/pkg/v1/action/errors.go: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | import "errors" 4 | 5 | var ( 6 | ErrNoResourcesFound = errors.New("no resources found") 7 | ErrNameAndSelector = errors.New("name cannot be provided when a selector is specified") 8 | ErrNoChange = errors.New("no changes detected - extension already in desired state") 9 | ) 10 | -------------------------------------------------------------------------------- /internal/pkg/v1/action/extension_delete.go: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strings" 8 | 9 | apierrors "k8s.io/apimachinery/pkg/api/errors" 10 | 11 | olmv1 "github.com/operator-framework/operator-controller/api/v1" 12 | 13 | "github.com/operator-framework/kubectl-operator/pkg/action" 14 | ) 15 | 16 | // ExtensionDeletion deletes an extension or all extensions in the cluster 17 | type ExtensionDeletion struct { 18 | config *action.Configuration 19 | ExtensionName string 20 | DeleteAll bool 21 | Logf func(string, ...interface{}) 22 | } 23 | 24 | // NewExtensionDelete creates a new ExtensionDeletion action 25 | // with the given configuration 26 | // and a logger function that can be used to log messages 27 | func NewExtensionDelete(cfg *action.Configuration) *ExtensionDeletion { 28 | return &ExtensionDeletion{ 29 | config: cfg, 30 | Logf: func(string, ...interface{}) {}, 31 | } 32 | } 33 | 34 | func (u *ExtensionDeletion) Run(ctx context.Context) ([]string, error) { 35 | if u.DeleteAll && u.ExtensionName != "" { 36 | return nil, fmt.Errorf("cannot specify both --all and an extension name") 37 | } 38 | if !u.DeleteAll { 39 | return u.deleteExtension(ctx, u.ExtensionName) 40 | } 41 | 42 | // delete all existing extensions 43 | return u.deleteAllExtensions(ctx) 44 | } 45 | 46 | // deleteExtension deletes a single extension in the cluster 47 | func (u *ExtensionDeletion) deleteExtension(ctx context.Context, extName string) ([]string, error) { 48 | op := &olmv1.ClusterExtension{} 49 | op.SetName(extName) 50 | op.SetGroupVersionKind(olmv1.GroupVersion.WithKind("ClusterExtension")) 51 | lowerKind := strings.ToLower(op.GetObjectKind().GroupVersionKind().Kind) 52 | err := u.config.Client.Delete(ctx, op) 53 | if err != nil { 54 | if !apierrors.IsNotFound(err) { 55 | return []string{u.ExtensionName}, fmt.Errorf("delete %s %q: %v", lowerKind, op.GetName(), err) 56 | } 57 | return nil, err 58 | } 59 | // wait for deletion 60 | return []string{u.ExtensionName}, waitForDeletion(ctx, u.config.Client, op) 61 | } 62 | 63 | // deleteAllExtensions deletes all extensions in the cluster 64 | func (u *ExtensionDeletion) deleteAllExtensions(ctx context.Context) ([]string, error) { 65 | var extensionList olmv1.ClusterExtensionList 66 | if err := u.config.Client.List(ctx, &extensionList); err != nil { 67 | return nil, err 68 | } 69 | if len(extensionList.Items) == 0 { 70 | return nil, ErrNoResourcesFound 71 | } 72 | errs := make([]error, 0, len(extensionList.Items)) 73 | names := make([]string, 0, len(extensionList.Items)) 74 | for _, extension := range extensionList.Items { 75 | names = append(names, extension.Name) 76 | if _, err := u.deleteExtension(ctx, extension.Name); err != nil { 77 | errs = append(errs, fmt.Errorf("failed deleting extension %q: %w", extension.Name, err)) 78 | } 79 | } 80 | return names, errors.Join(errs...) 81 | } 82 | -------------------------------------------------------------------------------- /internal/pkg/v1/action/extension_delete_test.go: -------------------------------------------------------------------------------- 1 | package action_test 2 | 3 | import ( 4 | "context" 5 | "slices" 6 | 7 | . "github.com/onsi/ginkgo" 8 | . "github.com/onsi/gomega" 9 | 10 | "sigs.k8s.io/controller-runtime/pkg/client" 11 | "sigs.k8s.io/controller-runtime/pkg/client/fake" 12 | 13 | olmv1 "github.com/operator-framework/operator-controller/api/v1" 14 | 15 | internalaction "github.com/operator-framework/kubectl-operator/internal/pkg/v1/action" 16 | "github.com/operator-framework/kubectl-operator/pkg/action" 17 | ) 18 | 19 | var _ = Describe("ExtensionDelete", func() { 20 | setupEnv := func(extensions ...client.Object) action.Configuration { 21 | var cfg action.Configuration 22 | 23 | sch, err := action.NewScheme() 24 | Expect(err).To(BeNil()) 25 | 26 | cl := fake.NewClientBuilder(). 27 | WithObjects(extensions...). 28 | WithScheme(sch). 29 | Build() 30 | cfg.Scheme = sch 31 | cfg.Client = cl 32 | 33 | return cfg 34 | } 35 | 36 | It("fails because of both extension name and --all specifier being present", func() { 37 | cfg := setupEnv(setupTestExtensions(2)...) 38 | 39 | deleter := internalaction.NewExtensionDelete(&cfg) 40 | deleter.ExtensionName = "foo" 41 | deleter.DeleteAll = true 42 | extNames, err := deleter.Run(context.TODO()) 43 | Expect(err).NotTo(BeNil()) 44 | Expect(extNames).To(BeEmpty()) 45 | 46 | validateExistingExtensions(cfg.Client, []string{"ext1", "ext2"}) 47 | }) 48 | 49 | It("fails deleting a non-existent extensions", func() { 50 | cfg := setupEnv(setupTestExtensions(2)...) 51 | 52 | deleter := internalaction.NewExtensionDelete(&cfg) 53 | deleter.ExtensionName = "does-not-exist" 54 | extNames, err := deleter.Run(context.TODO()) 55 | Expect(err).NotTo(BeNil()) 56 | Expect(extNames).To(BeEmpty()) 57 | 58 | validateExistingExtensions(cfg.Client, []string{"ext1", "ext2"}) 59 | }) 60 | 61 | It("successfully deletes an existing extension", func() { 62 | cfg := setupEnv(setupTestExtensions(3)...) 63 | 64 | deleter := internalaction.NewExtensionDelete(&cfg) 65 | deleter.ExtensionName = "ext2" 66 | _, err := deleter.Run(context.TODO()) 67 | Expect(err).To(BeNil()) 68 | 69 | validateExistingExtensions(cfg.Client, []string{"ext1", "ext3"}) 70 | }) 71 | 72 | It("fails deleting all extensions because there are none", func() { 73 | cfg := setupEnv() 74 | 75 | deleter := internalaction.NewExtensionDelete(&cfg) 76 | deleter.DeleteAll = true 77 | extNames, err := deleter.Run(context.TODO()) 78 | Expect(err).NotTo(BeNil()) 79 | Expect(extNames).To(BeEmpty()) 80 | 81 | validateExistingExtensions(cfg.Client, []string{}) 82 | }) 83 | 84 | It("successfully deletes all extensions", func() { 85 | cfg := setupEnv(setupTestExtensions(3)...) 86 | 87 | deleter := internalaction.NewExtensionDelete(&cfg) 88 | deleter.DeleteAll = true 89 | extNames, err := deleter.Run(context.TODO()) 90 | Expect(err).To(BeNil()) 91 | Expect(extNames).To(ContainElements([]string{"ext1", "ext2", "ext3"})) 92 | 93 | validateExistingExtensions(cfg.Client, []string{}) 94 | }) 95 | }) 96 | 97 | // validateExistingExtensions compares the names of the existing extensions with the wanted names 98 | // and ensures that all wanted names are present in the existing extensions 99 | func validateExistingExtensions(c client.Client, wantedNames []string) { 100 | var extensionList olmv1.ClusterExtensionList 101 | err := c.List(context.TODO(), &extensionList) 102 | Expect(err).To(BeNil()) 103 | 104 | extensions := extensionList.Items 105 | Expect(extensions).To(HaveLen(len(wantedNames))) 106 | for _, wantedName := range wantedNames { 107 | Expect(slices.ContainsFunc(extensions, func(ext olmv1.ClusterExtension) bool { 108 | return ext.Name == wantedName 109 | })).To(BeTrue()) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /internal/pkg/v1/action/extension_install.go: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "time" 8 | 9 | "k8s.io/apimachinery/pkg/api/meta" 10 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 11 | "k8s.io/apimachinery/pkg/util/wait" 12 | "sigs.k8s.io/controller-runtime/pkg/client" 13 | 14 | ocv1 "github.com/operator-framework/operator-controller/api/v1" 15 | 16 | "github.com/operator-framework/kubectl-operator/pkg/action" 17 | ) 18 | 19 | type ExtensionInstall struct { 20 | config *action.Configuration 21 | ExtensionName string 22 | Namespace NamespaceConfig 23 | PackageName string 24 | Channels []string 25 | Version string 26 | ServiceAccount string 27 | CleanupTimeout time.Duration 28 | Logf func(string, ...interface{}) 29 | } 30 | type NamespaceConfig struct { 31 | Name string 32 | } 33 | 34 | func NewExtensionInstall(cfg *action.Configuration) *ExtensionInstall { 35 | return &ExtensionInstall{ 36 | config: cfg, 37 | Logf: func(string, ...interface{}) {}, 38 | } 39 | } 40 | 41 | func (i *ExtensionInstall) buildClusterExtension() ocv1.ClusterExtension { 42 | extension := ocv1.ClusterExtension{ 43 | ObjectMeta: metav1.ObjectMeta{ 44 | Name: i.ExtensionName, 45 | }, 46 | Spec: ocv1.ClusterExtensionSpec{ 47 | Source: ocv1.SourceConfig{ 48 | SourceType: ocv1.SourceTypeCatalog, 49 | Catalog: &ocv1.CatalogFilter{ 50 | PackageName: i.PackageName, 51 | Version: i.Version, 52 | }, 53 | }, 54 | Namespace: i.Namespace.Name, 55 | ServiceAccount: ocv1.ServiceAccountReference{ 56 | Name: i.ServiceAccount, 57 | }, 58 | }, 59 | } 60 | 61 | return extension 62 | } 63 | 64 | func (i *ExtensionInstall) Run(ctx context.Context) (*ocv1.ClusterExtension, error) { 65 | extension := i.buildClusterExtension() 66 | 67 | // Add Channels to extension 68 | if len(i.Channels) > 0 { 69 | extension.Spec.Source.Catalog.Channels = i.Channels 70 | } 71 | 72 | // TODO: Add CatalogSelector to extension 73 | 74 | // Create the extension 75 | if err := i.config.Client.Create(ctx, &extension); err != nil { 76 | return nil, err 77 | } 78 | clusterExtension, err := i.waitForExtensionInstall(ctx) 79 | if err != nil { 80 | cleanupCtx, cancelCleanup := context.WithTimeout(context.Background(), i.CleanupTimeout) 81 | defer cancelCleanup() 82 | cleanupErr := i.cleanup(cleanupCtx) 83 | return nil, errors.Join(err, cleanupErr) 84 | } 85 | return clusterExtension, nil 86 | } 87 | 88 | // waitForClusterExtensionInstalled waits for the ClusterExtension to be installed 89 | // and returns the ClusterExtension object 90 | func (i *ExtensionInstall) waitForExtensionInstall(ctx context.Context) (*ocv1.ClusterExtension, error) { 91 | clusterExtension := &ocv1.ClusterExtension{ 92 | ObjectMeta: metav1.ObjectMeta{ 93 | Name: i.ExtensionName, 94 | }, 95 | } 96 | errMsg := "" 97 | key := client.ObjectKeyFromObject(clusterExtension) 98 | if err := wait.PollUntilContextCancel(ctx, time.Millisecond*250, true, func(conditionCtx context.Context) (bool, error) { 99 | if err := i.config.Client.Get(conditionCtx, key, clusterExtension); err != nil { 100 | return false, err 101 | } 102 | progressingCondition := meta.FindStatusCondition(clusterExtension.Status.Conditions, ocv1.TypeProgressing) 103 | if progressingCondition != nil && progressingCondition.Reason != ocv1.ReasonSucceeded { 104 | errMsg = progressingCondition.Message 105 | return false, nil 106 | } 107 | if !meta.IsStatusConditionPresentAndEqual(clusterExtension.Status.Conditions, ocv1.TypeInstalled, metav1.ConditionTrue) { 108 | return false, nil 109 | } 110 | return true, nil 111 | }); err != nil { 112 | if errMsg == "" { 113 | errMsg = err.Error() 114 | } 115 | return nil, fmt.Errorf("cluster extension %q did not finish installing: %s", clusterExtension.Name, errMsg) 116 | } 117 | return clusterExtension, nil 118 | } 119 | 120 | func (i *ExtensionInstall) cleanup(ctx context.Context) error { 121 | clusterExtension := &ocv1.ClusterExtension{ 122 | ObjectMeta: metav1.ObjectMeta{ 123 | Name: i.ExtensionName, 124 | }, 125 | } 126 | if err := waitForDeletion(ctx, i.config.Client, clusterExtension); err != nil { 127 | return fmt.Errorf("delete clusterextension %q: %v", i.ExtensionName, err) 128 | } 129 | return nil 130 | } 131 | -------------------------------------------------------------------------------- /internal/pkg/v1/action/extension_install_test.go: -------------------------------------------------------------------------------- 1 | package action_test 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "time" 7 | 8 | . "github.com/onsi/ginkgo" 9 | . "github.com/onsi/gomega" 10 | 11 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 12 | 13 | ocv1 "github.com/operator-framework/operator-controller/api/v1" 14 | 15 | internalaction "github.com/operator-framework/kubectl-operator/internal/pkg/v1/action" 16 | "github.com/operator-framework/kubectl-operator/pkg/action" 17 | ) 18 | 19 | var _ = Describe("InstallExtension", func() { 20 | extensionName := "testExtension" 21 | packageName := "testPackage" 22 | packageVersion := "1.0.0" 23 | serviceAccount := "testServiceAccount" 24 | namespace := "testNamespace" 25 | 26 | expectedExtension := ocv1.ClusterExtension{ 27 | ObjectMeta: metav1.ObjectMeta{ 28 | Name: extensionName, 29 | }, 30 | Spec: ocv1.ClusterExtensionSpec{ 31 | Source: ocv1.SourceConfig{ 32 | SourceType: ocv1.SourceTypeCatalog, 33 | Catalog: &ocv1.CatalogFilter{ 34 | PackageName: packageName, 35 | Version: packageVersion, 36 | }, 37 | }, 38 | Namespace: namespace, 39 | ServiceAccount: ocv1.ServiceAccountReference{ 40 | Name: serviceAccount, 41 | }, 42 | }, 43 | } 44 | It("Cluster extension install fails", func() { 45 | expectedErr := errors.New("extension install failed") 46 | testClient := fakeClient{createErr: expectedErr} 47 | Expect(testClient.Initialize()).To(Succeed()) 48 | 49 | installer := internalaction.NewExtensionInstall(&action.Configuration{Client: testClient}) 50 | installer.ExtensionName = expectedExtension.Name 51 | installer.PackageName = expectedExtension.Spec.Source.Catalog.PackageName 52 | installer.Channels = expectedExtension.Spec.Source.Catalog.Channels 53 | installer.Version = expectedExtension.Spec.Source.Catalog.Version 54 | installer.ServiceAccount = expectedExtension.Spec.ServiceAccount.Name 55 | installer.CleanupTimeout = 1 * time.Minute 56 | installer.Namespace.Name = expectedExtension.Spec.Namespace 57 | _, err := installer.Run(context.TODO()) 58 | 59 | Expect(err).NotTo(BeNil()) 60 | Expect(err).To(MatchError(expectedErr)) 61 | Expect(testClient.createCalled).To(Equal(1)) 62 | }) 63 | }) 64 | -------------------------------------------------------------------------------- /internal/pkg/v1/action/extension_installed_get.go: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | import ( 4 | "context" 5 | 6 | "k8s.io/apimachinery/pkg/types" 7 | "sigs.k8s.io/controller-runtime/pkg/client" 8 | 9 | olmv1 "github.com/operator-framework/operator-controller/api/v1" 10 | 11 | "github.com/operator-framework/kubectl-operator/pkg/action" 12 | ) 13 | 14 | type ExtensionInstalledGet struct { 15 | config *action.Configuration 16 | ExtensionName string 17 | 18 | Logf func(string, ...interface{}) 19 | } 20 | 21 | func NewExtensionInstalledGet(cfg *action.Configuration) *ExtensionInstalledGet { 22 | return &ExtensionInstalledGet{ 23 | config: cfg, 24 | Logf: func(string, ...interface{}) {}, 25 | } 26 | } 27 | 28 | func (i *ExtensionInstalledGet) Run(ctx context.Context) ([]olmv1.ClusterExtension, error) { 29 | // get 30 | if i.ExtensionName != "" { 31 | var result olmv1.ClusterExtension 32 | opKey := types.NamespacedName{Name: i.ExtensionName} 33 | err := i.config.Client.Get(ctx, opKey, &result) 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | return []olmv1.ClusterExtension{result}, nil 39 | } 40 | 41 | // list 42 | var result olmv1.ClusterExtensionList 43 | err := i.config.Client.List(ctx, &result, &client.ListOptions{}) 44 | 45 | return result.Items, err 46 | } 47 | -------------------------------------------------------------------------------- /internal/pkg/v1/action/extension_installed_get_test.go: -------------------------------------------------------------------------------- 1 | package action_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "slices" 7 | 8 | . "github.com/onsi/ginkgo" 9 | . "github.com/onsi/gomega" 10 | 11 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 12 | "sigs.k8s.io/controller-runtime/pkg/client" 13 | "sigs.k8s.io/controller-runtime/pkg/client/fake" 14 | 15 | olmv1 "github.com/operator-framework/operator-controller/api/v1" 16 | 17 | internalaction "github.com/operator-framework/kubectl-operator/internal/pkg/v1/action" 18 | "github.com/operator-framework/kubectl-operator/pkg/action" 19 | ) 20 | 21 | var _ = Describe("ExtensionInstalledGet", func() { 22 | setupEnv := func(extensions ...client.Object) action.Configuration { 23 | var cfg action.Configuration 24 | 25 | sch, err := action.NewScheme() 26 | Expect(err).To(BeNil()) 27 | 28 | cl := fake.NewClientBuilder(). 29 | WithObjects(extensions...). 30 | WithScheme(sch). 31 | Build() 32 | cfg.Scheme = sch 33 | cfg.Client = cl 34 | 35 | return cfg 36 | } 37 | 38 | It("lists all installed extensions", func() { 39 | cfg := setupEnv(setupTestExtensions(3)...) 40 | 41 | getter := internalaction.NewExtensionInstalledGet(&cfg) 42 | extensions, err := getter.Run(context.TODO()) 43 | Expect(err).To(BeNil()) 44 | Expect(extensions).NotTo(BeEmpty()) 45 | Expect(extensions).To(HaveLen(3)) 46 | 47 | for _, testExtensionName := range []string{"ext1", "ext2", "ext3"} { 48 | Expect(slices.ContainsFunc(extensions, func(op olmv1.ClusterExtension) bool { 49 | return op.Name == testExtensionName 50 | })).To(BeTrue()) 51 | } 52 | }) 53 | 54 | It("returns empty list in case no extensions were found", func() { 55 | cfg := setupEnv() 56 | 57 | getter := internalaction.NewExtensionInstalledGet(&cfg) 58 | extensions, err := getter.Run(context.TODO()) 59 | Expect(err).To(BeNil()) 60 | Expect(extensions).To(BeEmpty()) 61 | }) 62 | 63 | It("gets an installed extension", func() { 64 | cfg := setupEnv(setupTestExtensions(3)...) 65 | 66 | getter := internalaction.NewExtensionInstalledGet(&cfg) 67 | getter.ExtensionName = "ext2" 68 | extensions, err := getter.Run(context.TODO()) 69 | Expect(err).To(BeNil()) 70 | Expect(extensions).NotTo(BeEmpty()) 71 | Expect(extensions).To(HaveLen(1)) 72 | Expect(extensions[0].Name).To(Equal("ext2")) 73 | }) 74 | 75 | It("returns an empty list and an error when an installed extension was not found", func() { 76 | cfg := setupEnv() 77 | 78 | getter := internalaction.NewExtensionInstalledGet(&cfg) 79 | getter.ExtensionName = "ext2" 80 | extensions, err := getter.Run(context.TODO()) 81 | Expect(err).NotTo(BeNil()) 82 | Expect(extensions).To(BeEmpty()) 83 | }) 84 | }) 85 | 86 | func setupTestExtensions(n int) []client.Object { 87 | var result []client.Object 88 | for i := 1; i <= n; i++ { 89 | result = append(result, newClusterExtension(fmt.Sprintf("ext%d", i), fmt.Sprintf("%d.0", n))) 90 | } 91 | 92 | return result 93 | } 94 | 95 | func newClusterExtension(name, version string) *olmv1.ClusterExtension { 96 | return &olmv1.ClusterExtension{ 97 | ObjectMeta: metav1.ObjectMeta{Name: name}, 98 | Status: olmv1.ClusterExtensionStatus{ 99 | Install: &olmv1.ClusterExtensionInstallStatus{ 100 | Bundle: olmv1.BundleMetadata{ 101 | Name: name, 102 | Version: version, 103 | }, 104 | }, 105 | }, 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /internal/pkg/v1/action/extension_update.go: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "maps" 7 | "slices" 8 | "time" 9 | 10 | "github.com/blang/semver/v4" 11 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 12 | "k8s.io/apimachinery/pkg/types" 13 | 14 | olmv1 "github.com/operator-framework/operator-controller/api/v1" 15 | 16 | "github.com/operator-framework/kubectl-operator/pkg/action" 17 | ) 18 | 19 | type ExtensionUpdate struct { 20 | cfg *action.Configuration 21 | 22 | Package string 23 | 24 | Version string 25 | Channels []string 26 | Selector string 27 | // parsedSelector is used internally to avoid potentially costly transformations 28 | // between string and metav1.LabelSelector formats 29 | parsedSelector *metav1.LabelSelector 30 | UpgradeConstraintPolicy string 31 | Labels map[string]string 32 | IgnoreUnset bool 33 | 34 | CleanupTimeout time.Duration 35 | 36 | Logf func(string, ...interface{}) 37 | } 38 | 39 | func NewExtensionUpdate(cfg *action.Configuration) *ExtensionUpdate { 40 | return &ExtensionUpdate{ 41 | cfg: cfg, 42 | Logf: func(string, ...interface{}) {}, 43 | } 44 | } 45 | 46 | func (ou *ExtensionUpdate) Run(ctx context.Context) (*olmv1.ClusterExtension, error) { 47 | var ext olmv1.ClusterExtension 48 | var err error 49 | 50 | opKey := types.NamespacedName{Name: ou.Package} 51 | if err = ou.cfg.Client.Get(ctx, opKey, &ext); err != nil { 52 | return nil, err 53 | } 54 | 55 | if ext.Spec.Source.SourceType != olmv1.SourceTypeCatalog { 56 | return nil, fmt.Errorf("unrecognized source type: %q", ext.Spec.Source.SourceType) 57 | } 58 | 59 | ou.setDefaults(ext) 60 | 61 | if ou.Version != "" { 62 | if _, err = semver.ParseRange(ou.Version); err != nil { 63 | return nil, fmt.Errorf("failed parsing version: %w", err) 64 | } 65 | } 66 | if ou.Selector != "" && ou.parsedSelector == nil { 67 | ou.parsedSelector, err = metav1.ParseToLabelSelector(ou.Selector) 68 | if err != nil { 69 | return nil, fmt.Errorf("failed parsing selector: %w", err) 70 | } 71 | } 72 | 73 | constraintPolicy := olmv1.UpgradeConstraintPolicy(ou.UpgradeConstraintPolicy) 74 | if !ou.needsUpdate(ext, constraintPolicy) { 75 | return nil, ErrNoChange 76 | } 77 | 78 | ou.prepareUpdatedExtension(&ext, constraintPolicy) 79 | if err := ou.cfg.Client.Update(ctx, &ext); err != nil { 80 | return nil, err 81 | } 82 | 83 | if err := waitUntilExtensionStatusCondition(ctx, ou.cfg.Client, &ext, olmv1.TypeInstalled, metav1.ConditionTrue); err != nil { 84 | return nil, fmt.Errorf("timed out waiting for extension: %w", err) 85 | } 86 | 87 | return &ext, nil 88 | } 89 | 90 | func (ou *ExtensionUpdate) setDefaults(ext olmv1.ClusterExtension) { 91 | if !ou.IgnoreUnset { 92 | if ou.UpgradeConstraintPolicy == "" { 93 | ou.UpgradeConstraintPolicy = string(olmv1.UpgradeConstraintPolicyCatalogProvided) 94 | } 95 | 96 | return 97 | } 98 | 99 | // IgnoreUnset is enabled 100 | // set all unset values to what they are on the current object 101 | catalogSrc := ext.Spec.Source.Catalog 102 | if ou.Version == "" { 103 | ou.Version = catalogSrc.Version 104 | } 105 | if len(ou.Channels) == 0 { 106 | ou.Channels = catalogSrc.Channels 107 | } 108 | if ou.UpgradeConstraintPolicy == "" { 109 | ou.UpgradeConstraintPolicy = string(catalogSrc.UpgradeConstraintPolicy) 110 | } 111 | if len(ou.Labels) == 0 { 112 | ou.Labels = ext.Labels 113 | } 114 | if ou.Selector == "" && catalogSrc.Selector != nil { 115 | ou.parsedSelector = catalogSrc.Selector 116 | } 117 | } 118 | 119 | func (ou *ExtensionUpdate) needsUpdate(ext olmv1.ClusterExtension, constraintPolicy olmv1.UpgradeConstraintPolicy) bool { 120 | catalogSrc := ext.Spec.Source.Catalog 121 | 122 | // object string form is used for comparison to: 123 | // - remove the need for potentially costly metav1.FormatLabelSelector calls 124 | // - avoid having to handle potential reordering of items from on cluster state 125 | sameSelectors := (catalogSrc.Selector == nil && ou.parsedSelector == nil) || 126 | (catalogSrc.Selector != nil && ou.parsedSelector != nil && 127 | catalogSrc.Selector.String() == ou.parsedSelector.String()) 128 | 129 | if catalogSrc.Version == ou.Version && 130 | slices.Equal(catalogSrc.Channels, ou.Channels) && 131 | catalogSrc.UpgradeConstraintPolicy == constraintPolicy && 132 | maps.Equal(ext.Labels, ou.Labels) && 133 | sameSelectors { 134 | return false 135 | } 136 | 137 | return true 138 | } 139 | 140 | func (ou *ExtensionUpdate) prepareUpdatedExtension(ext *olmv1.ClusterExtension, constraintPolicy olmv1.UpgradeConstraintPolicy) { 141 | ext.SetLabels(ou.Labels) 142 | ext.Spec.Source.Catalog.Version = ou.Version 143 | ext.Spec.Source.Catalog.Selector = ou.parsedSelector 144 | ext.Spec.Source.Catalog.Channels = ou.Channels 145 | ext.Spec.Source.Catalog.UpgradeConstraintPolicy = constraintPolicy 146 | } 147 | -------------------------------------------------------------------------------- /internal/pkg/v1/action/extension_update_test.go: -------------------------------------------------------------------------------- 1 | package action_test 2 | 3 | import ( 4 | "context" 5 | "maps" 6 | "time" 7 | 8 | . "github.com/onsi/ginkgo" 9 | . "github.com/onsi/gomega" 10 | 11 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 12 | "sigs.k8s.io/controller-runtime/pkg/client" 13 | "sigs.k8s.io/controller-runtime/pkg/client/fake" 14 | 15 | olmv1 "github.com/operator-framework/operator-controller/api/v1" 16 | 17 | internalaction "github.com/operator-framework/kubectl-operator/internal/pkg/v1/action" 18 | "github.com/operator-framework/kubectl-operator/pkg/action" 19 | ) 20 | 21 | var _ = Describe("ExtensionUpdate", func() { 22 | setupEnv := func(extensions ...client.Object) action.Configuration { 23 | var cfg action.Configuration 24 | 25 | sch, err := action.NewScheme() 26 | Expect(err).To(BeNil()) 27 | 28 | cl := fake.NewClientBuilder(). 29 | WithObjects(extensions...). 30 | WithScheme(sch). 31 | Build() 32 | cfg.Scheme = sch 33 | cfg.Client = cl 34 | 35 | return cfg 36 | } 37 | 38 | It("fails finding existing extension", func() { 39 | cfg := setupEnv() 40 | 41 | updater := internalaction.NewExtensionUpdate(&cfg) 42 | updater.Package = "does-not-exist" 43 | ext, err := updater.Run(context.TODO()) 44 | 45 | Expect(err).NotTo(BeNil()) 46 | Expect(err.Error()).To(ContainSubstring("not found")) 47 | Expect(ext).To(BeNil()) 48 | }) 49 | 50 | It("fails to handle extension with non-catalog source type", func() { 51 | cfg := setupEnv(buildExtension("test", withSourceType("unknown"))) 52 | 53 | updater := internalaction.NewExtensionUpdate(&cfg) 54 | updater.Package = "test" 55 | ext, err := updater.Run(context.TODO()) 56 | 57 | Expect(err).NotTo(BeNil()) 58 | Expect(err.Error()).To(ContainSubstring("unrecognized source type")) 59 | Expect(ext).To(BeNil()) 60 | }) 61 | 62 | It("fails because desired extension state matches current", func() { 63 | cfg := setupEnv(buildExtension( 64 | "test", 65 | withSourceType(olmv1.SourceTypeCatalog), 66 | withConstraintPolicy(string(olmv1.UpgradeConstraintPolicyCatalogProvided))), 67 | ) 68 | 69 | updater := internalaction.NewExtensionUpdate(&cfg) 70 | updater.Package = "test" 71 | ext, err := updater.Run(context.TODO()) 72 | 73 | Expect(err).NotTo(BeNil()) 74 | Expect(err).To(MatchError(internalaction.ErrNoChange)) 75 | Expect(ext).To(BeNil()) 76 | }) 77 | 78 | It("fails because desired extension state matches current with IgnoreUnset enabled", func() { 79 | cfg := setupEnv(buildExtension( 80 | "test", 81 | withSourceType(olmv1.SourceTypeCatalog), 82 | withConstraintPolicy(string(olmv1.UpgradeConstraintPolicyCatalogProvided)), 83 | withChannels("a", "b"), 84 | withLabels(map[string]string{"c": "d"}), 85 | withVersion("10.0.4"), 86 | )) 87 | 88 | updater := internalaction.NewExtensionUpdate(&cfg) 89 | updater.Package = "test" 90 | updater.IgnoreUnset = true 91 | ext, err := updater.Run(context.TODO()) 92 | 93 | Expect(err).NotTo(BeNil()) 94 | Expect(err).To(MatchError(internalaction.ErrNoChange)) 95 | Expect(ext).To(BeNil()) 96 | }) 97 | 98 | It("fails validating extension version", func() { 99 | cfg := setupEnv(buildExtension( 100 | "test", 101 | withSourceType(olmv1.SourceTypeCatalog), 102 | withConstraintPolicy(string(olmv1.UpgradeConstraintPolicyCatalogProvided))), 103 | ) 104 | 105 | updater := internalaction.NewExtensionUpdate(&cfg) 106 | updater.Package = "test" 107 | updater.Version = "10-4" 108 | ext, err := updater.Run(context.TODO()) 109 | 110 | Expect(err).NotTo(BeNil()) 111 | Expect(err.Error()).To(ContainSubstring("parsing version")) 112 | Expect(ext).To(BeNil()) 113 | }) 114 | 115 | It("fails updating extension", func() { 116 | testExt := buildExtension( 117 | "test", 118 | withSourceType(olmv1.SourceTypeCatalog), 119 | withConstraintPolicy(string(olmv1.UpgradeConstraintPolicyCatalogProvided)), 120 | ) 121 | cfg := setupEnv(testExt) 122 | 123 | ctx, cancel := context.WithCancel(context.TODO()) 124 | cancel() 125 | 126 | updater := internalaction.NewExtensionUpdate(&cfg) 127 | updater.Package = "test" 128 | updater.Version = "10.0.4" 129 | updater.Channels = []string{"a", "b"} 130 | updater.Labels = map[string]string{"c": "d"} 131 | updater.UpgradeConstraintPolicy = string(olmv1.UpgradeConstraintPolicySelfCertified) 132 | ext, err := updater.Run(ctx) 133 | 134 | Expect(err).NotTo(BeNil()) 135 | Expect(err.Error()).To(ContainSubstring("timed out")) 136 | Expect(ext).To(BeNil()) 137 | }) 138 | 139 | It("successfully updates extension", func() { 140 | testExt := buildExtension( 141 | "test", 142 | withSourceType(olmv1.SourceTypeCatalog), 143 | withConstraintPolicy(string(olmv1.UpgradeConstraintPolicyCatalogProvided)), 144 | ) 145 | cfg := setupEnv(testExt, buildExtension("test2"), buildExtension("test3")) 146 | 147 | go func() { 148 | Eventually(updateExtensionConditionStatus). 149 | WithArguments("test", cfg.Client, olmv1.TypeInstalled, metav1.ConditionTrue). 150 | WithTimeout(5 * time.Second).WithPolling(200 * time.Millisecond). 151 | Should(Succeed()) 152 | }() 153 | 154 | updater := internalaction.NewExtensionUpdate(&cfg) 155 | updater.Package = "test" 156 | updater.Version = "10.0.4" 157 | updater.Channels = []string{"a", "b"} 158 | updater.Labels = map[string]string{"c": "d"} 159 | updater.UpgradeConstraintPolicy = string(olmv1.UpgradeConstraintPolicySelfCertified) 160 | ext, err := updater.Run(context.TODO()) 161 | 162 | Expect(err).To(BeNil()) 163 | Expect(ext).NotTo(BeNil()) 164 | Expect(ext.Spec.Source.Catalog.Version).To(Equal(updater.Version)) 165 | Expect(maps.Equal(ext.Labels, updater.Labels)).To(BeTrue()) 166 | Expect(ext.Spec.Source.Catalog.Channels).To(ContainElements(updater.Channels)) 167 | Expect(ext.Spec.Source.Catalog.UpgradeConstraintPolicy). 168 | To(Equal(olmv1.UpgradeConstraintPolicy(updater.UpgradeConstraintPolicy))) 169 | 170 | // also verify that other objects were not updated 171 | validateNonUpdatedExtensions(cfg.Client, "test") 172 | }) 173 | }) 174 | 175 | func validateNonUpdatedExtensions(c client.Client, exceptName string) { 176 | var extList olmv1.ClusterExtensionList 177 | err := c.List(context.TODO(), &extList) 178 | Expect(err).To(BeNil()) 179 | 180 | for _, ext := range extList.Items { 181 | if ext.Name == exceptName { 182 | continue 183 | } 184 | 185 | Expect(ext.Spec.Source.Catalog.Version).To(BeEmpty()) 186 | Expect(ext.Labels).To(BeEmpty()) 187 | Expect(ext.Spec.Source.Catalog.Channels).To(BeEmpty()) 188 | Expect(ext.Spec.Source.Catalog.UpgradeConstraintPolicy).To(BeEmpty()) 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /internal/pkg/v1/action/helpers.go: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "slices" 7 | "strings" 8 | "time" 9 | 10 | apierrors "k8s.io/apimachinery/pkg/api/errors" 11 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 12 | "k8s.io/apimachinery/pkg/types" 13 | "k8s.io/apimachinery/pkg/util/wait" 14 | "sigs.k8s.io/controller-runtime/pkg/client" 15 | 16 | olmv1 "github.com/operator-framework/operator-controller/api/v1" 17 | ) 18 | 19 | const pollInterval = 250 * time.Millisecond 20 | 21 | func objectKeyForObject(obj client.Object) types.NamespacedName { 22 | return types.NamespacedName{ 23 | Namespace: obj.GetNamespace(), 24 | Name: obj.GetName(), 25 | } 26 | } 27 | 28 | func waitUntilCatalogStatusCondition( 29 | ctx context.Context, 30 | cl getter, 31 | catalog *olmv1.ClusterCatalog, 32 | conditionType string, 33 | conditionStatus metav1.ConditionStatus, 34 | ) error { 35 | opKey := objectKeyForObject(catalog) 36 | return wait.PollUntilContextCancel(ctx, pollInterval, true, func(conditionCtx context.Context) (bool, error) { 37 | if err := cl.Get(conditionCtx, opKey, catalog); err != nil { 38 | return false, err 39 | } 40 | 41 | if slices.ContainsFunc(catalog.Status.Conditions, func(cond metav1.Condition) bool { 42 | return cond.Type == conditionType && cond.Status == conditionStatus 43 | }) { 44 | return true, nil 45 | } 46 | return false, nil 47 | }) 48 | } 49 | 50 | func waitUntilExtensionStatusCondition( 51 | ctx context.Context, 52 | cl getter, 53 | extension *olmv1.ClusterExtension, 54 | conditionType string, 55 | conditionStatus metav1.ConditionStatus, 56 | ) error { 57 | opKey := objectKeyForObject(extension) 58 | return wait.PollUntilContextCancel(ctx, pollInterval, true, func(conditionCtx context.Context) (bool, error) { 59 | if err := cl.Get(conditionCtx, opKey, extension); err != nil { 60 | return false, err 61 | } 62 | 63 | if slices.ContainsFunc(extension.Status.Conditions, func(cond metav1.Condition) bool { 64 | return cond.Type == conditionType && cond.Status == conditionStatus 65 | }) { 66 | return true, nil 67 | } 68 | return false, nil 69 | }) 70 | } 71 | 72 | func deleteWithTimeout(cl deleter, obj client.Object, timeout time.Duration) error { 73 | ctx, cancel := context.WithTimeout(context.Background(), timeout) 74 | defer cancel() 75 | 76 | if err := cl.Delete(ctx, obj); err != nil && !apierrors.IsNotFound(err) { 77 | return err 78 | } 79 | 80 | return nil 81 | } 82 | 83 | func waitForDeletion(ctx context.Context, cl getter, objs ...client.Object) error { 84 | for _, obj := range objs { 85 | lowerKind := strings.ToLower(obj.GetObjectKind().GroupVersionKind().Kind) 86 | key := objectKeyForObject(obj) 87 | if err := wait.PollUntilContextCancel(ctx, pollInterval, true, func(conditionCtx context.Context) (bool, error) { 88 | if err := cl.Get(conditionCtx, key, obj); apierrors.IsNotFound(err) { 89 | return true, nil 90 | } else if err != nil { 91 | return false, err 92 | } 93 | return false, nil 94 | }); err != nil { 95 | return fmt.Errorf("wait for %s %q deleted: %v", lowerKind, key.Name, err) 96 | } 97 | } 98 | return nil 99 | } 100 | -------------------------------------------------------------------------------- /internal/pkg/v1/action/interfaces.go: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | import ( 4 | "context" 5 | 6 | "sigs.k8s.io/controller-runtime/pkg/client" 7 | ) 8 | 9 | type deleter interface { 10 | Delete(ctx context.Context, obj client.Object, opts ...client.DeleteOption) error 11 | } 12 | 13 | type getter interface { 14 | Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error 15 | } 16 | -------------------------------------------------------------------------------- /internal/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "fmt" 5 | "runtime" 6 | ) 7 | 8 | const ( 9 | unknown = "unknown" 10 | ) 11 | 12 | var ( 13 | GitVersion = unknown 14 | GitCommit = unknown 15 | GitCommitTime = unknown 16 | GitTreeState = unknown 17 | GoVersion = runtime.Version() 18 | Compiler = runtime.Compiler 19 | Platform = fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH) 20 | ) 21 | 22 | type Info struct { 23 | GitVersion string 24 | GitCommit string 25 | GitCommitTime string 26 | GitTreeState string 27 | GoVersion string 28 | Compiler string 29 | Platform string 30 | } 31 | 32 | var Version Info 33 | 34 | func init() { 35 | Version = Info{ 36 | GitVersion: GitVersion, 37 | GitCommit: GitCommit, 38 | GitCommitTime: GitCommitTime, 39 | GitTreeState: GitTreeState, 40 | GoVersion: GoVersion, 41 | Compiler: Compiler, 42 | Platform: Platform, 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/operator-framework/kubectl-operator/internal/cmd" 5 | ) 6 | 7 | func main() { 8 | cmd.Execute() 9 | } 10 | -------------------------------------------------------------------------------- /pkg/action/action_suite_test.go: -------------------------------------------------------------------------------- 1 | package action_test 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestAction(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "Action Suite") 13 | } 14 | -------------------------------------------------------------------------------- /pkg/action/config.go: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/spf13/pflag" 7 | apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" 8 | "k8s.io/apimachinery/pkg/runtime" 9 | "k8s.io/client-go/tools/clientcmd" 10 | "sigs.k8s.io/controller-runtime/pkg/client" 11 | 12 | v1 "github.com/operator-framework/api/pkg/operators/v1" 13 | "github.com/operator-framework/api/pkg/operators/v1alpha1" 14 | olmv1 "github.com/operator-framework/operator-controller/api/v1" 15 | operatorsv1 "github.com/operator-framework/operator-lifecycle-manager/pkg/package-server/apis/operators/v1" 16 | ) 17 | 18 | func NewScheme() (*runtime.Scheme, error) { 19 | sch := runtime.NewScheme() 20 | for _, f := range []func(*runtime.Scheme) error{ 21 | v1alpha1.AddToScheme, 22 | operatorsv1.AddToScheme, 23 | v1.AddToScheme, 24 | apiextensionsv1.AddToScheme, 25 | olmv1.AddToScheme, 26 | } { 27 | if err := f(sch); err != nil { 28 | return nil, err 29 | } 30 | } 31 | return sch, nil 32 | } 33 | 34 | type Configuration struct { 35 | Client client.Client 36 | Namespace string 37 | Scheme *runtime.Scheme 38 | 39 | overrides *clientcmd.ConfigOverrides 40 | } 41 | 42 | func (c *Configuration) BindFlags(fs *pflag.FlagSet) { 43 | if c.overrides == nil { 44 | c.overrides = &clientcmd.ConfigOverrides{} 45 | } 46 | clientcmd.BindOverrideFlags(c.overrides, fs, clientcmd.ConfigOverrideFlags{ 47 | ContextOverrideFlags: clientcmd.ContextOverrideFlags{ 48 | Namespace: clientcmd.FlagInfo{ 49 | LongName: "namespace", 50 | ShortName: "n", 51 | Default: "", 52 | Description: "If present, namespace scope for this CLI request", 53 | }, 54 | }, 55 | }) 56 | } 57 | 58 | func (c *Configuration) Load() error { 59 | if c.overrides == nil { 60 | c.overrides = &clientcmd.ConfigOverrides{} 61 | } 62 | loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() 63 | mergedConfig, err := loadingRules.Load() 64 | if err != nil { 65 | return err 66 | } 67 | cfg := clientcmd.NewDefaultClientConfig(*mergedConfig, c.overrides) 68 | cc, err := cfg.ClientConfig() 69 | if err != nil { 70 | return err 71 | } 72 | 73 | ns, _, err := cfg.Namespace() 74 | if err != nil { 75 | return err 76 | } 77 | 78 | sch, err := NewScheme() 79 | if err != nil { 80 | return err 81 | } 82 | cl, err := client.New(cc, client.Options{ 83 | Scheme: sch, 84 | }) 85 | if err != nil { 86 | return err 87 | } 88 | 89 | c.Scheme = sch 90 | c.Client = &operatorClient{cl} 91 | c.Namespace = ns 92 | 93 | return nil 94 | } 95 | 96 | type operatorClient struct { 97 | client.Client 98 | } 99 | 100 | func (c *operatorClient) Create(ctx context.Context, obj client.Object, opts ...client.CreateOption) error { 101 | opts = append(opts, client.FieldOwner("kubectl-operator")) 102 | return c.Client.Create(ctx, obj, opts...) 103 | } 104 | -------------------------------------------------------------------------------- /pkg/action/operator_list_operands.go: -------------------------------------------------------------------------------- 1 | package action 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sort" 7 | 8 | apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" 9 | apierrors "k8s.io/apimachinery/pkg/api/errors" 10 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 11 | "k8s.io/apimachinery/pkg/runtime/schema" 12 | "k8s.io/apimachinery/pkg/types" 13 | "sigs.k8s.io/controller-runtime/pkg/client" 14 | 15 | v1 "github.com/operator-framework/api/pkg/operators/v1" 16 | "github.com/operator-framework/api/pkg/operators/v1alpha1" 17 | ) 18 | 19 | // OperatorListOperands knows how to find and list custom resources given a package name and namespace. 20 | type OperatorListOperands struct { 21 | config *Configuration 22 | } 23 | 24 | func NewOperatorListOperands(cfg *Configuration) *OperatorListOperands { 25 | return &OperatorListOperands{ 26 | config: cfg, 27 | } 28 | } 29 | 30 | func (o *OperatorListOperands) Run(ctx context.Context, packageName string) (*unstructured.UnstructuredList, error) { 31 | result, err := o.listAll(ctx, packageName) 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | return result, nil 37 | } 38 | 39 | // FindOperator finds an operator object on-cluster provided a package and namespace. 40 | func (o *OperatorListOperands) findOperator(ctx context.Context, packageName string) (*v1.Operator, error) { 41 | opKey := types.NamespacedName{ 42 | Name: fmt.Sprintf("%s.%s", packageName, o.config.Namespace), 43 | } 44 | 45 | operator := v1.Operator{} 46 | err := o.config.Client.Get(ctx, opKey, &operator) 47 | if err != nil { 48 | if apierrors.IsNotFound(err) { 49 | return nil, fmt.Errorf("package %q not found in namespace %q", packageName, o.config.Namespace) 50 | } 51 | return nil, err 52 | } 53 | return &operator, nil 54 | } 55 | 56 | // Unzip finds the CSV referenced by the provided operator and then inspects the spec.customresourcedefinitions.owned 57 | // section of the CSV to return a list of APIs that are owned by the CSV. 58 | func (o *OperatorListOperands) unzip(ctx context.Context, operator *v1.Operator) ([]v1alpha1.CRDDescription, error) { 59 | csv := v1alpha1.ClusterServiceVersion{} 60 | csvKey := types.NamespacedName{} 61 | 62 | if operator.Status.Components == nil { 63 | return nil, fmt.Errorf("could not find underlying components for operator %s", operator.Name) 64 | } 65 | for _, resource := range operator.Status.Components.Refs { 66 | if resource.Kind == v1alpha1.ClusterServiceVersionKind { 67 | csvKey.Name = resource.Name 68 | csvKey.Namespace = resource.Namespace 69 | break 70 | } 71 | } 72 | 73 | if csvKey.Name == "" && csvKey.Namespace == "" { 74 | return nil, fmt.Errorf("could not find underlying CSV for operator %s", operator.Name) 75 | } 76 | 77 | err := o.config.Client.Get(ctx, csvKey, &csv) 78 | if err != nil { 79 | return nil, fmt.Errorf("could not get %s CSV on cluster: %s", csvKey.String(), err) 80 | } 81 | 82 | // check if owned CRDs are defined on the csv 83 | if len(csv.Spec.CustomResourceDefinitions.Owned) == 0 { 84 | return nil, fmt.Errorf("no owned CustomResourceDefinitions specified on CSV %s, no custom resources to display", csvKey.String()) 85 | } 86 | 87 | // check CSV is not in a failed state (to ensure some OLM multitenancy rules are not violated) 88 | if csv.Status.Phase != v1alpha1.CSVPhaseSucceeded { 89 | return nil, fmt.Errorf("CSV underlying operator is not in a succeeded state: custom resource list may not be accurate") 90 | } 91 | 92 | return csv.Spec.CustomResourceDefinitions.Owned, nil 93 | } 94 | 95 | // List takes in a CRD description and finds the associated CRs on-cluster. 96 | // List can return a potentially unbounded list that callers may need to paginate. 97 | func (o *OperatorListOperands) list(ctx context.Context, crdDesc v1alpha1.CRDDescription, namespaces []string) (*unstructured.UnstructuredList, error) { 98 | result := &unstructured.UnstructuredList{} 99 | 100 | // get crd group from crd name 101 | crd := apiextensionsv1.CustomResourceDefinition{} 102 | crdKey := types.NamespacedName{ 103 | Name: crdDesc.Name, 104 | } 105 | err := o.config.Client.Get(ctx, crdKey, &crd) 106 | if err != nil { 107 | return nil, fmt.Errorf("get crd %q: %v", crdKey.String(), err) 108 | } 109 | 110 | list := unstructured.UnstructuredList{} 111 | gvk := schema.GroupVersionKind{ 112 | Group: crd.Spec.Group, 113 | Version: crdDesc.Version, 114 | Kind: crd.Spec.Names.ListKind, 115 | } 116 | list.SetGroupVersionKind(gvk) 117 | if err := o.config.Client.List(ctx, &list); err != nil { 118 | return nil, err 119 | } 120 | 121 | // trim down CRs in list to match target namespaces 122 | if len(namespaces) == 0 { 123 | return &list, nil 124 | } 125 | for _, cr := range list.Items { 126 | if cr.GetNamespace() == "" || inNamespace(cr.GetNamespace(), namespaces) { 127 | result.Items = append(result.Items, cr) 128 | } 129 | } 130 | 131 | return result, nil 132 | } 133 | 134 | // ListAll wraps the above functions to provide a convenient command to go from package/namespace to custom resources. 135 | func (o *OperatorListOperands) listAll(ctx context.Context, packageName string) (*unstructured.UnstructuredList, error) { 136 | operator, err := o.findOperator(ctx, packageName) 137 | if err != nil { 138 | return nil, err 139 | } 140 | 141 | crdDescs, err := o.unzip(ctx, operator) 142 | if err != nil { 143 | return nil, err 144 | } 145 | 146 | // find all namespaces associated with operator via operatorgroup 147 | // query for CRs in these namespaces 148 | ogList := v1.OperatorGroupList{} 149 | options := client.ListOptions{Namespace: o.config.Namespace} 150 | if err := o.config.Client.List(ctx, &ogList, &options); err != nil { 151 | return nil, err 152 | } 153 | if len(ogList.Items) != 1 { 154 | return nil, fmt.Errorf("unexpected number (%d) of operator groups found in namespace %s", len(ogList.Items), o.config.Namespace) 155 | } 156 | namespaces := ogList.Items[0].Status.Namespaces 157 | 158 | var result unstructured.UnstructuredList 159 | result.SetGroupVersionKind(schema.GroupVersionKind{ 160 | Version: "v1", 161 | Kind: "List", 162 | }) 163 | for _, crd := range crdDescs { 164 | list, err := o.list(ctx, crd, namespaces) 165 | if err != nil { 166 | return nil, err 167 | } 168 | result.Items = append(result.Items, list.Items...) 169 | } 170 | 171 | // sort results 172 | sort.Slice(result.Items, func(i, j int) bool { 173 | if result.Items[i].GetAPIVersion() != result.Items[j].GetAPIVersion() { 174 | return result.Items[i].GetAPIVersion() < result.Items[j].GetAPIVersion() 175 | } 176 | if result.Items[i].GetKind() != result.Items[j].GetKind() { 177 | return result.Items[i].GetKind() < result.Items[j].GetKind() 178 | } 179 | if result.Items[i].GetNamespace() != result.Items[j].GetNamespace() { 180 | return result.Items[i].GetNamespace() < result.Items[j].GetNamespace() 181 | } 182 | return result.Items[i].GetName() < result.Items[j].GetName() 183 | }) 184 | 185 | return &result, nil 186 | } 187 | 188 | func inNamespace(ns string, namespaces []string) bool { 189 | for _, n := range namespaces { 190 | if n == ns || n == "" { 191 | return true 192 | } 193 | } 194 | return false 195 | } 196 | -------------------------------------------------------------------------------- /pkg/action/operator_list_operands_test.go: -------------------------------------------------------------------------------- 1 | package action_test 2 | 3 | import ( 4 | "context" 5 | 6 | . "github.com/onsi/ginkgo" 7 | . "github.com/onsi/gomega" 8 | 9 | corev1 "k8s.io/api/core/v1" 10 | apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" 11 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 12 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 13 | "k8s.io/apimachinery/pkg/runtime/schema" 14 | "k8s.io/apimachinery/pkg/types" 15 | "sigs.k8s.io/controller-runtime/pkg/client/fake" 16 | 17 | v1 "github.com/operator-framework/api/pkg/operators/v1" 18 | "github.com/operator-framework/api/pkg/operators/v1alpha1" 19 | 20 | "github.com/operator-framework/kubectl-operator/pkg/action" 21 | ) 22 | 23 | var _ = Describe("OperatorListOperands", func() { 24 | var ( 25 | cfg action.Configuration 26 | operator *v1.Operator 27 | csv *v1alpha1.ClusterServiceVersion 28 | crd *apiextensionsv1.CustomResourceDefinition 29 | og *v1.OperatorGroup 30 | etcdcluster1 *unstructured.Unstructured 31 | etcdcluster2 *unstructured.Unstructured 32 | etcdcluster3 *unstructured.Unstructured 33 | ) 34 | 35 | BeforeEach(func() { 36 | sch, err := action.NewScheme() 37 | Expect(err).To(BeNil()) 38 | 39 | etcdclusterGVK := schema.GroupVersionKind{ 40 | Group: "etcd.database.coreos.com", 41 | Version: "v1beta2", 42 | Kind: "EtcdCluster", 43 | } 44 | 45 | sch.AddKnownTypeWithName(etcdclusterGVK, &unstructured.Unstructured{}) 46 | sch.AddKnownTypeWithName(schema.GroupVersionKind{ 47 | Group: "etcd.database.coreos.com", 48 | Version: "v1beta2", 49 | Kind: "EtcdClusterList", 50 | }, &unstructured.UnstructuredList{}) 51 | 52 | operator = &v1.Operator{ 53 | ObjectMeta: metav1.ObjectMeta{Name: "etcd.etcd-namespace"}, 54 | Status: v1.OperatorStatus{ 55 | Components: &v1.Components{ 56 | Refs: []v1.RichReference{ 57 | { 58 | ObjectReference: &corev1.ObjectReference{ 59 | APIVersion: "operators.coreos.com/v1alpha1", 60 | Kind: "ClusterServiceVersion", 61 | Name: "etcdoperator.v0.9.4-clusterwide", 62 | Namespace: "etcd-namespace", 63 | }, 64 | }, 65 | }, 66 | }, 67 | }, 68 | } 69 | 70 | csv = &v1alpha1.ClusterServiceVersion{ 71 | ObjectMeta: metav1.ObjectMeta{ 72 | Name: "etcdoperator.v0.9.4-clusterwide", 73 | Namespace: "etcd-namespace", 74 | }, 75 | Spec: v1alpha1.ClusterServiceVersionSpec{ 76 | CustomResourceDefinitions: v1alpha1.CustomResourceDefinitions{ 77 | Owned: []v1alpha1.CRDDescription{ 78 | { 79 | Name: "etcdclusters.etcd.database.coreos.com", 80 | Version: "v1beta2", 81 | Kind: "EtcdCluster", 82 | }, 83 | }, 84 | }, 85 | }, 86 | Status: v1alpha1.ClusterServiceVersionStatus{Phase: v1alpha1.CSVPhaseSucceeded}, 87 | } 88 | 89 | og = &v1.OperatorGroup{ 90 | ObjectMeta: metav1.ObjectMeta{ 91 | Name: "etcd", 92 | Namespace: "etcd-namespace", 93 | }, 94 | Status: v1.OperatorGroupStatus{Namespaces: []string{""}}, 95 | } 96 | 97 | crd = &apiextensionsv1.CustomResourceDefinition{ 98 | ObjectMeta: metav1.ObjectMeta{ 99 | Name: "etcdclusters.etcd.database.coreos.com", 100 | }, 101 | Spec: apiextensionsv1.CustomResourceDefinitionSpec{ 102 | Group: "etcd.database.coreos.com", 103 | Names: apiextensionsv1.CustomResourceDefinitionNames{ 104 | ListKind: "EtcdClusterList", 105 | }, 106 | }, 107 | } 108 | etcdcluster1 = &unstructured.Unstructured{} 109 | etcdcluster1.SetGroupVersionKind(etcdclusterGVK) 110 | etcdcluster1.SetNamespace("ns1") 111 | etcdcluster1.SetName("cluster1") 112 | 113 | etcdcluster2 = &unstructured.Unstructured{} 114 | etcdcluster2.SetGroupVersionKind(etcdclusterGVK) 115 | etcdcluster2.SetNamespace("ns2") 116 | etcdcluster2.SetName("cluster2") 117 | 118 | etcdcluster3 = &unstructured.Unstructured{} 119 | etcdcluster3.SetGroupVersionKind(etcdclusterGVK) 120 | // Empty namespace to simulate cluster-scoped object. 121 | etcdcluster3.SetNamespace("") 122 | etcdcluster3.SetName("cluster3") 123 | 124 | cl := fake.NewClientBuilder(). 125 | WithObjects(operator, csv, og, crd, etcdcluster1, etcdcluster2, etcdcluster3). 126 | WithScheme(sch). 127 | Build() 128 | cfg.Scheme = sch 129 | cfg.Client = cl 130 | cfg.Namespace = "etcd-namespace" 131 | }) 132 | 133 | It("should fail due to missing operator", func() { 134 | lister := action.NewOperatorListOperands(&cfg) 135 | _, err := lister.Run(context.TODO(), "missing") 136 | Expect(err.Error()).To(ContainSubstring(`package "missing" not found in namespace "etcd-namespace"`)) 137 | }) 138 | 139 | It("should fail due to missing operator components", func() { 140 | operator.Status.Components = nil 141 | Expect(cfg.Client.Update(context.TODO(), operator)).To(Succeed()) 142 | 143 | lister := action.NewOperatorListOperands(&cfg) 144 | _, err := lister.Run(context.TODO(), "etcd") 145 | Expect(err.Error()).To(ContainSubstring("could not find underlying components for operator")) 146 | }) 147 | 148 | It("should fail due to missing CSV in operator components", func() { 149 | operator.Status.Components = &v1.Components{} 150 | Expect(cfg.Client.Update(context.TODO(), operator)).To(Succeed()) 151 | 152 | lister := action.NewOperatorListOperands(&cfg) 153 | _, err := lister.Run(context.TODO(), "etcd") 154 | Expect(err.Error()).To(ContainSubstring("could not find underlying CSV for operator")) 155 | }) 156 | 157 | It("should fail due to missing CSV in cluster", func() { 158 | Expect(cfg.Client.Delete(context.TODO(), csv)).To(Succeed()) 159 | 160 | lister := action.NewOperatorListOperands(&cfg) 161 | _, err := lister.Run(context.TODO(), "etcd") 162 | Expect(err.Error()).To(ContainSubstring("could not get etcd-namespace/etcdoperator.v0.9.4-clusterwide CSV on cluster")) 163 | }) 164 | 165 | It("should fail if the CSV has no owned CRDs", func() { 166 | csv.Spec.CustomResourceDefinitions.Owned = nil 167 | Expect(cfg.Client.Update(context.TODO(), csv)).To(Succeed()) 168 | 169 | lister := action.NewOperatorListOperands(&cfg) 170 | _, err := lister.Run(context.TODO(), "etcd") 171 | Expect(err.Error()).To(ContainSubstring("no owned CustomResourceDefinitions specified on CSV etcd-namespace/etcdoperator.v0.9.4-clusterwide")) 172 | }) 173 | 174 | It("should fail if the CSV is not in phase Succeeded", func() { 175 | csv.Status.Phase = v1alpha1.CSVPhaseFailed 176 | Expect(cfg.Client.Update(context.TODO(), csv)).To(Succeed()) 177 | 178 | lister := action.NewOperatorListOperands(&cfg) 179 | _, err := lister.Run(context.TODO(), "etcd") 180 | Expect(err.Error()).To(ContainSubstring("CSV underlying operator is not in a succeeded state")) 181 | }) 182 | 183 | It("should fail if there is not exactly 1 operator group", func() { 184 | Expect(cfg.Client.Delete(context.TODO(), og)).To(Succeed()) 185 | 186 | lister := action.NewOperatorListOperands(&cfg) 187 | _, err := lister.Run(context.TODO(), "etcd") 188 | Expect(err.Error()).To(ContainSubstring("unexpected number (0) of operator groups found in namespace etcd")) 189 | }) 190 | 191 | It("should fail if an owned CRD does not exist", func() { 192 | Expect(cfg.Client.Delete(context.TODO(), crd)).To(Succeed()) 193 | 194 | lister := action.NewOperatorListOperands(&cfg) 195 | _, err := lister.Run(context.TODO(), "etcd") 196 | Expect(err.Error()).To(ContainSubstring("customresourcedefinitions.apiextensions.k8s.io \"etcdclusters.etcd.database.coreos.com\" not found")) 197 | }) 198 | 199 | It("should return zero operands when none exist", func() { 200 | Expect(cfg.Client.Delete(context.TODO(), etcdcluster1)).To(Succeed()) 201 | Expect(cfg.Client.Delete(context.TODO(), etcdcluster2)).To(Succeed()) 202 | Expect(cfg.Client.Delete(context.TODO(), etcdcluster3)).To(Succeed()) 203 | 204 | lister := action.NewOperatorListOperands(&cfg) 205 | operands, err := lister.Run(context.TODO(), "etcd") 206 | Expect(err).To(BeNil()) 207 | Expect(operands.Items).To(HaveLen(0)) 208 | }) 209 | 210 | It("should return operands from all namespaces", func() { 211 | lister := action.NewOperatorListOperands(&cfg) 212 | operands, err := lister.Run(context.TODO(), "etcd") 213 | Expect(err).To(BeNil()) 214 | Expect(getObjectNames(*operands)).To(ConsistOf( 215 | types.NamespacedName{Name: "cluster1", Namespace: "ns1"}, 216 | types.NamespacedName{Name: "cluster2", Namespace: "ns2"}, 217 | types.NamespacedName{Name: "cluster3", Namespace: ""}, 218 | )) 219 | }) 220 | 221 | It("should return operands from scoped namespaces", func() { 222 | og.Status.Namespaces = []string{"ns1", "ns2"} 223 | Expect(cfg.Client.Update(context.TODO(), og)).To(Succeed()) 224 | 225 | lister := action.NewOperatorListOperands(&cfg) 226 | operands, err := lister.Run(context.TODO(), "etcd") 227 | Expect(err).To(BeNil()) 228 | Expect(getObjectNames(*operands)).To(ConsistOf( 229 | types.NamespacedName{Name: "cluster1", Namespace: "ns1"}, 230 | types.NamespacedName{Name: "cluster2", Namespace: "ns2"}, 231 | types.NamespacedName{Name: "cluster3", Namespace: ""}, 232 | )) 233 | }) 234 | 235 | It("should return operands from scoped namespace ns1", func() { 236 | og.Status.Namespaces = []string{"ns1"} 237 | Expect(cfg.Client.Update(context.TODO(), og)).To(Succeed()) 238 | 239 | lister := action.NewOperatorListOperands(&cfg) 240 | operands, err := lister.Run(context.TODO(), "etcd") 241 | Expect(err).To(BeNil()) 242 | Expect(getObjectNames(*operands)).To(ConsistOf( 243 | types.NamespacedName{Name: "cluster1", Namespace: "ns1"}, 244 | types.NamespacedName{Name: "cluster3", Namespace: ""}, 245 | )) 246 | }) 247 | 248 | It("should return operands from scoped namespace ns2", func() { 249 | og.Status.Namespaces = []string{"ns2"} 250 | Expect(cfg.Client.Update(context.TODO(), og)).To(Succeed()) 251 | 252 | lister := action.NewOperatorListOperands(&cfg) 253 | operands, err := lister.Run(context.TODO(), "etcd") 254 | Expect(err).To(BeNil()) 255 | Expect(getObjectNames(*operands)).To(ConsistOf( 256 | types.NamespacedName{Name: "cluster2", Namespace: "ns2"}, 257 | types.NamespacedName{Name: "cluster3", Namespace: ""}, 258 | )) 259 | }) 260 | 261 | It("should return cluster-scoped operands regardless of operator groups targetnamespaces", func() { 262 | og.Status.Namespaces = []string{"other"} 263 | Expect(cfg.Client.Update(context.TODO(), og)).To(Succeed()) 264 | 265 | lister := action.NewOperatorListOperands(&cfg) 266 | operands, err := lister.Run(context.TODO(), "etcd") 267 | Expect(err).To(BeNil()) 268 | Expect(getObjectNames(*operands)).To(ConsistOf( 269 | types.NamespacedName{Name: "cluster3", Namespace: ""}, 270 | )) 271 | }) 272 | }) 273 | 274 | func getObjectNames(objects unstructured.UnstructuredList) []types.NamespacedName { 275 | out := []types.NamespacedName{} 276 | for _, u := range objects.Items { 277 | out = append(out, types.NamespacedName{Name: u.GetName(), Namespace: u.GetNamespace()}) 278 | } 279 | return out 280 | } 281 | --------------------------------------------------------------------------------