diff --git a/.bingo/Variables.mk b/.bingo/Variables.mk index 71ae6bcc3..8c08ceade 100644 --- a/.bingo/Variables.mk +++ b/.bingo/Variables.mk @@ -47,11 +47,11 @@ $(GOJQ): $(BINGO_DIR)/gojq.mod @echo "(re)installing $(GOBIN)/gojq-v0.12.17" @cd $(BINGO_DIR) && GOWORK=off $(GO) build -mod=mod -modfile=gojq.mod -o=$(GOBIN)/gojq-v0.12.17 "github.com/itchyny/gojq/cmd/gojq" -GOLANGCI_LINT := $(GOBIN)/golangci-lint-v2.1.6 +GOLANGCI_LINT := $(GOBIN)/golangci-lint-v2.6.2 $(GOLANGCI_LINT): $(BINGO_DIR)/golangci-lint.mod @# Install binary/ries using Go 1.14+ build command. This is using bwplotka/bingo-controlled, separate go module with pinned dependencies. - @echo "(re)installing $(GOBIN)/golangci-lint-v2.1.6" - @cd $(BINGO_DIR) && GOWORK=off $(GO) build -mod=mod -modfile=golangci-lint.mod -o=$(GOBIN)/golangci-lint-v2.1.6 "github.com/golangci/golangci-lint/v2/cmd/golangci-lint" + @echo "(re)installing $(GOBIN)/golangci-lint-v2.6.2" + @cd $(BINGO_DIR) && GOWORK=off $(GO) build -mod=mod -modfile=golangci-lint.mod -o=$(GOBIN)/golangci-lint-v2.6.2 "github.com/golangci/golangci-lint/v2/cmd/golangci-lint" GORELEASER := $(GOBIN)/goreleaser-v1.26.2 $(GORELEASER): $(BINGO_DIR)/goreleaser.mod diff --git a/.bingo/golangci-lint.mod b/.bingo/golangci-lint.mod index 07ecc9aa8..4607edf92 100644 --- a/.bingo/golangci-lint.mod +++ b/.bingo/golangci-lint.mod @@ -1,7 +1,5 @@ module _ // Auto generated by https://github.com/bwplotka/bingo. DO NOT EDIT -go 1.24.2 +go 1.24.6 -toolchain go1.24.3 - -require github.com/golangci/golangci-lint/v2 v2.1.6 // cmd/golangci-lint +require github.com/golangci/golangci-lint/v2 v2.6.2 // cmd/golangci-lint diff --git a/.bingo/golangci-lint.sum b/.bingo/golangci-lint.sum index 17881e374..3146c7150 100644 --- a/.bingo/golangci-lint.sum +++ b/.bingo/golangci-lint.sum @@ -34,31 +34,41 @@ cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0Zeo cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= +codeberg.org/chavacava/garif v0.2.0 h1:F0tVjhYbuOCnvNcU3YSpO6b3Waw6Bimy4K0mM8y6MfY= +codeberg.org/chavacava/garif v0.2.0/go.mod h1:P2BPbVbT4QcvLZrORc2T29szK3xEOlnl0GiPTJmEqBQ= +dev.gaijin.team/go/exhaustruct/v4 v4.0.0 h1:873r7aNneqoBB3IaFIzhvt2RFYTuHgmMjoKfwODoI1Y= +dev.gaijin.team/go/exhaustruct/v4 v4.0.0/go.mod h1:aZ/k2o4Y05aMJtiux15x8iXaumE88YdiB0Ai4fXOzPI= +dev.gaijin.team/go/golib v0.6.0 h1:v6nnznFTs4bppib/NyU1PQxobwDHwCXXl15P7DV5Zgo= +dev.gaijin.team/go/golib v0.6.0/go.mod h1:uY1mShx8Z/aNHWDyAkZTkX+uCi5PdX7KsG1eDQa2AVE= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= -github.com/4meepo/tagalign v1.4.2 h1:0hcLHPGMjDyM1gHG58cS73aQF8J4TdVR96TZViorO9E= -github.com/4meepo/tagalign v1.4.2/go.mod h1:+p4aMyFM+ra7nb41CnFG6aSDXqRxU/w1VQqScKqDARI= -github.com/Abirdcfly/dupword v0.1.3 h1:9Pa1NuAsZvpFPi9Pqkd93I7LIYRURj+A//dFd5tgBeE= -github.com/Abirdcfly/dupword v0.1.3/go.mod h1:8VbB2t7e10KRNdwTVoxdBaxla6avbhGzb8sCTygUMhw= -github.com/Antonboom/errname v1.1.0 h1:A+ucvdpMwlo/myWrkHEUEBWc/xuXdud23S8tmTb/oAE= -github.com/Antonboom/errname v1.1.0/go.mod h1:O1NMrzgUcVBGIfi3xlVuvX8Q/VP/73sseCaAppfjqZw= -github.com/Antonboom/nilnil v1.1.0 h1:jGxJxjgYS3VUUtOTNk8Z1icwT5ESpLH/426fjmQG+ng= -github.com/Antonboom/nilnil v1.1.0/go.mod h1:b7sAlogQjFa1wV8jUW3o4PMzDVFLbTux+xnQdvzdcIE= -github.com/Antonboom/testifylint v1.6.1 h1:6ZSytkFWatT8mwZlmRCHkWz1gPi+q6UBSbieji2Gj/o= -github.com/Antonboom/testifylint v1.6.1/go.mod h1:k+nEkathI2NFjKO6HvwmSrbzUcQ6FAnbZV+ZRrnXPLI= +github.com/4meepo/tagalign v1.4.3 h1:Bnu7jGWwbfpAie2vyl63Zup5KuRv21olsPIha53BJr8= +github.com/4meepo/tagalign v1.4.3/go.mod h1:00WwRjiuSbrRJnSVeGWPLp2epS5Q/l4UEy0apLLS37c= +github.com/Abirdcfly/dupword v0.1.7 h1:2j8sInznrje4I0CMisSL6ipEBkeJUJAmK1/lfoNGWrQ= +github.com/Abirdcfly/dupword v0.1.7/go.mod h1:K0DkBeOebJ4VyOICFdppB23Q0YMOgVafM0zYW0n9lF4= +github.com/AdminBenni/iota-mixing v1.0.0 h1:Os6lpjG2dp/AE5fYBPAA1zfa2qMdCAWwPMCgpwKq7wo= +github.com/AdminBenni/iota-mixing v1.0.0/go.mod h1:i4+tpAaB+qMVIV9OK3m4/DAynOd5bQFaOu+2AhtBCNY= +github.com/AlwxSin/noinlineerr v1.0.5 h1:RUjt63wk1AYWTXtVXbSqemlbVTb23JOSRiNsshj7TbY= +github.com/AlwxSin/noinlineerr v1.0.5/go.mod h1:+QgkkoYrMH7RHvcdxdlI7vYYEdgeoFOVjU9sUhw/rQc= +github.com/Antonboom/errname v1.1.1 h1:bllB7mlIbTVzO9jmSWVWLjxTEbGBVQ1Ff/ClQgtPw9Q= +github.com/Antonboom/errname v1.1.1/go.mod h1:gjhe24xoxXp0ScLtHzjiXp0Exi1RFLKJb0bVBtWKCWQ= +github.com/Antonboom/nilnil v1.1.1 h1:9Mdr6BYd8WHCDngQnNVV0b554xyisFioEKi30sksufQ= +github.com/Antonboom/nilnil v1.1.1/go.mod h1:yCyAmSw3doopbOWhJlVci+HuyNRuHJKIv6V2oYQa8II= +github.com/Antonboom/testifylint v1.6.4 h1:gs9fUEy+egzxkEbq9P4cpcMB6/G0DYdMeiFS87UiqmQ= +github.com/Antonboom/testifylint v1.6.4/go.mod h1:YO33FROXX2OoUfwjz8g+gUxQXio5i9qpVy7nXGbxDD4= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24 h1:sHglBQTwgx+rWPdisA5ynNEsoARbiCBOyGcJM4/OzsM= -github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24/go.mod h1:4UJr5HIiMZrwgkSPdsjy2uOQExX/WEILpIrO9UPGuXs= -github.com/GaijinEntertainment/go-exhaustruct/v3 v3.3.1 h1:Sz1JIXEcSfhz7fUi7xHnhpIE0thVASYjvosApmHuD2k= -github.com/GaijinEntertainment/go-exhaustruct/v3 v3.3.1/go.mod h1:n/LSCXNuIYqVfBlVXyHfMQkZDdp1/mmxfSjADd3z1Zg= -github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4= -github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Djarvur/go-err113 v0.1.1 h1:eHfopDqXRwAi+YmCUas75ZE0+hoBHJ2GQNLYRSxao4g= +github.com/Djarvur/go-err113 v0.1.1/go.mod h1:IaWJdYFLg76t2ihfflPZnM1LIQszWOsFDh2hhhAVF6k= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/MirrexOne/unqueryvet v1.2.1 h1:M+zdXMq84g+E1YOLa7g7ExN3dWfZQrdDSTCM7gC+m/A= +github.com/MirrexOne/unqueryvet v1.2.1/go.mod h1:IWwCwMQlSWjAIteW0t+28Q5vouyktfujzYznSIWiuOg= github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsuj3piCMx4= github.com/OpenPeeDeeP/depguard/v2 v2.2.1/go.mod h1:q4DKzC4UcVaAvcfd41CZh0PWpGgzrVxUYBlgKNGquUo= -github.com/alecthomas/chroma/v2 v2.17.2 h1:Rm81SCZ2mPoH+Q8ZCc/9YvzPUN/E7HgPiPJD8SLV6GI= -github.com/alecthomas/chroma/v2 v2.17.2/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk= +github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= +github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= github.com/alecthomas/go-check-sumtype v0.3.1 h1:u9aUvbGINJxLVXiFvHUlPEaD7VDULsrxJb4Aq31NLkU= github.com/alecthomas/go-check-sumtype v0.3.1/go.mod h1:A8TSiN3UPRw3laIgWEUOHHLPa6/r9MtoigdlP5h3K/E= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= @@ -70,14 +80,16 @@ github.com/alexkohler/nakedret/v2 v2.0.6 h1:ME3Qef1/KIKr3kWX3nti3hhgNxw6aqN5pZmQ github.com/alexkohler/nakedret/v2 v2.0.6/go.mod h1:l3RKju/IzOMQHmsEvXwkqMDzHHvurNQfAgE1eVmT40Q= github.com/alexkohler/prealloc v1.0.0 h1:Hbq0/3fJPQhNkN0dR95AVrr6R7tou91y0uHG5pOcUuw= github.com/alexkohler/prealloc v1.0.0/go.mod h1:VetnK3dIgFBBKmg0YnD9F9x6Icjd+9cvfHR56wJVlKE= +github.com/alfatraining/structtag v1.0.0 h1:2qmcUqNcCoyVJ0up879K614L9PazjBSFruTB0GOFjCc= +github.com/alfatraining/structtag v1.0.0/go.mod h1:p3Xi5SwzTi+Ryj64DqjLWz7XurHxbGsq6y3ubePJPus= github.com/alingse/asasalint v0.0.11 h1:SFwnQXJ49Kx/1GghOFz1XGqHYKp21Kq1nHad/0WQRnw= github.com/alingse/asasalint v0.0.11/go.mod h1:nCaoMhw7a9kSJObvQyVzNTPBDbNpdocqrSP7t/cW5+I= github.com/alingse/nilnesserr v0.2.0 h1:raLem5KG7EFVb4UIDAXgrv3N2JIaffeKNtcEXkEWd/w= github.com/alingse/nilnesserr v0.2.0/go.mod h1:1xJPrXonEtX7wyTq8Dytns5P2hNzoWymVUIaKm4HNFg= -github.com/ashanbrown/forbidigo v1.6.0 h1:D3aewfM37Yb3pxHujIPSpTf6oQk9sc9WZi8gerOIVIY= -github.com/ashanbrown/forbidigo v1.6.0/go.mod h1:Y8j9jy9ZYAEHXdu723cUlraTqbzjKF1MUyfOKL+AjcU= -github.com/ashanbrown/makezero v1.2.0 h1:/2Lp1bypdmK9wDIq7uWBlDF1iMUpIIS4A+pF6C9IEUU= -github.com/ashanbrown/makezero v1.2.0/go.mod h1:dxlPhHbDMC6N6xICzFBSK+4njQDdK8euNO0qjQMtGY4= +github.com/ashanbrown/forbidigo/v2 v2.3.0 h1:OZZDOchCgsX5gvToVtEBoV2UWbFfI6RKQTir2UZzSxo= +github.com/ashanbrown/forbidigo/v2 v2.3.0/go.mod h1:5p6VmsG5/1xx3E785W9fouMxIOkvY2rRV9nMdWadd6c= +github.com/ashanbrown/makezero/v2 v2.1.0 h1:snuKYMbqosNokUKm+R6/+vOPs8yVAi46La7Ck6QYSaE= +github.com/ashanbrown/makezero/v2 v2.1.0/go.mod h1:aEGT/9q3S8DHeE57C88z2a6xydvgx8J5hgXIGWgo0MY= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= @@ -90,6 +102,8 @@ github.com/blizzy78/varnamelen v0.8.0 h1:oqSblyuQvFsW1hbBHh1zfwrKe3kcSj0rnXkKzsQ github.com/blizzy78/varnamelen v0.8.0/go.mod h1:V9TzQZ4fLJ1DSrjVDfl89H7aMnTvKkApdHeyESmyR7k= github.com/bombsimon/wsl/v4 v4.7.0 h1:1Ilm9JBPRczjyUs6hvOPKvd7VL1Q++PL8M0SXBDf+jQ= github.com/bombsimon/wsl/v4 v4.7.0/go.mod h1:uV/+6BkffuzSAVYD+yGyld1AChO7/EuLrCF/8xTiapg= +github.com/bombsimon/wsl/v5 v5.3.0 h1:nZWREJFL6U3vgW/B1lfDOigl+tEF6qgs6dGGbFeR0UM= +github.com/bombsimon/wsl/v5 v5.3.0/go.mod h1:Gp8lD04z27wm3FANIUPZycXp+8huVsn0oxc+n4qfV9I= github.com/breml/bidichk v0.3.3 h1:WSM67ztRusf1sMoqH6/c4OBCUlRVTKq+CbSeo0R17sE= github.com/breml/bidichk v0.3.3/go.mod h1:ISbsut8OnjB367j5NseXEGGgO/th206dVa427kR8YTE= github.com/breml/errchkjson v0.4.1 h1:keFSS8D7A2T0haP9kzZTi7o26r7kE3vymjZNeNDRDwg= @@ -98,17 +112,17 @@ github.com/butuzov/ireturn v0.4.0 h1:+s76bF/PfeKEdbG8b54aCocxXmi0wvYdOVsWxVO7n8E github.com/butuzov/ireturn v0.4.0/go.mod h1:ghI0FrCmap8pDWZwfPisFD1vEc56VKH4NpQUxDHta70= github.com/butuzov/mirror v1.3.0 h1:HdWCXzmwlQHdVhwvsfBb2Au0r3HyINry3bDWLYXiKoc= github.com/butuzov/mirror v1.3.0/go.mod h1:AEij0Z8YMALaq4yQj9CPPVYOyJQyiexpQEQgihajRfI= -github.com/catenacyber/perfsprint v0.9.1 h1:5LlTp4RwTooQjJCvGEFV6XksZvWE7wCOUvjD2z0vls0= -github.com/catenacyber/perfsprint v0.9.1/go.mod h1:q//VWC2fWbcdSLEY1R3l8n0zQCDPdE4IjZwyY1HMunM= -github.com/ccojocar/zxcvbn-go v1.0.2 h1:na/czXU8RrhXO4EZme6eQJLR4PzcGsahsBOAwU6I3Vg= -github.com/ccojocar/zxcvbn-go v1.0.2/go.mod h1:g1qkXtUSvHP8lhHp5GrSmTz6uWALGRMQdw6Qnz/hi60= +github.com/catenacyber/perfsprint v0.10.0 h1:AZj1mYyxbxLRqmnYOeguZXEQwWOgQGm2wzLI5d7Hl/0= +github.com/catenacyber/perfsprint v0.10.0/go.mod h1:DJTGsi/Zufpuus6XPGJyKOTMELe347o6akPvWG9Zcsc= +github.com/ccojocar/zxcvbn-go v1.0.4 h1:FWnCIRMXPj43ukfX000kvBZvV6raSxakYr1nzyNrUcc= +github.com/ccojocar/zxcvbn-go v1.0.4/go.mod h1:3GxGX+rHmueTUMvm5ium7irpyjmm7ikxYFOSJB21Das= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/charithe/durationcheck v0.0.10 h1:wgw73BiocdBDQPik+zcEoBG/ob8uyBHf2iyoHGPf5w4= -github.com/charithe/durationcheck v0.0.10/go.mod h1:bCWXb7gYRysD1CU3C+u4ceO49LoGOY1C1L6uouGNreQ= +github.com/charithe/durationcheck v0.0.11 h1:g1/EX1eIiKS57NTWsYtHDZ/APfeXKhye1DidBcABctk= +github.com/charithe/durationcheck v0.0.11/go.mod h1:x5iZaixRNl8ctbM+3B2RrPG5t856TxRyVQEnbIEM2X4= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= @@ -119,8 +133,6 @@ github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0G github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= -github.com/chavacava/garif v0.1.0 h1:2JHa3hbYf5D9dsgseMKAmc/MZ109otzgNFk5s87H9Pc= -github.com/chavacava/garif v0.1.0/go.mod h1:XMyYCkEL58DF0oyW4qDjjnPWONs2HBqYKI+UIPD+Gww= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= @@ -131,8 +143,8 @@ github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGX github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/curioswitch/go-reassign v0.3.0 h1:dh3kpQHuADL3cobV/sSGETA8DOv457dwl+fbBAhrQPs= github.com/curioswitch/go-reassign v0.3.0/go.mod h1:nApPCCTtqLJN/s8HfItCcKV0jIPwluBOvZP+dsJGA88= -github.com/daixiang0/gci v0.13.6 h1:RKuEOSkGpSadkGbvZ6hJ4ddItT3cVZ9Vn9Rybk6xjl8= -github.com/daixiang0/gci v0.13.6/go.mod h1:12etP2OniiIdP4q+kjUGrC/rUagga7ODbqsom5Eo5Yk= +github.com/daixiang0/gci v0.13.7 h1:+0bG5eK9vlI08J+J/NWGbWPTNiXPG4WhNLJOkSxWITQ= +github.com/daixiang0/gci v0.13.7/go.mod h1:812WVN6JLFY9S6Tv76twqmNqevN0pa3SX3nih0brVzQ= github.com/dave/dst v0.27.3 h1:P1HPoMza3cMEquVf9kKy8yXsFirry4zEnWOdYPOoIzY= github.com/dave/dst v0.27.3/go.mod h1:jHh6EOibnHgcUW3WjKHisiooEkYwqpHLBSX1iOBhEyc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -158,10 +170,10 @@ github.com/fsnotify/fsnotify v1.5.4 h1:jRbGcIw6P2Meqdwuo0H1p6JVLbL5DHKAKlYndzMwV github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU= github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo= github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA= -github.com/ghostiam/protogetter v0.3.15 h1:1KF5sXel0HE48zh1/vn0Loiw25A9ApyseLzQuif1mLY= -github.com/ghostiam/protogetter v0.3.15/go.mod h1:WZ0nw9pfzsgxuRsPOFQomgDVSWtDLJRfQJEhsGbmQMA= -github.com/go-critic/go-critic v0.13.0 h1:kJzM7wzltQasSUXtYyTl6UaPVySO6GkaR1thFnJ6afY= -github.com/go-critic/go-critic v0.13.0/go.mod h1:M/YeuJ3vOCQDnP2SU+ZhjgRzwzcBW87JqLpMJLrZDLI= +github.com/ghostiam/protogetter v0.3.17 h1:sjGPErP9o7i2Ym+z3LsQzBdLCNaqbYy2iJQPxGXg04Q= +github.com/ghostiam/protogetter v0.3.17/go.mod h1:AivIX1eKA/TcUmzZdzbl+Tb8tjIe8FcyG6JFyemQAH4= +github.com/go-critic/go-critic v0.14.2 h1:PMvP5f+LdR8p6B29npvChUXbD1vrNlKDf60NJtgMBOo= +github.com/go-critic/go-critic v0.14.2/go.mod h1:xwntfW6SYAd7h1OqDzmN6hBX/JxsEKl5up/Y2bsxgVQ= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= @@ -189,14 +201,16 @@ github.com/go-toolsmith/strparse v1.1.0 h1:GAioeZUK9TGxnLS+qfdqNbA4z0SSm5zVNtCQi github.com/go-toolsmith/strparse v1.1.0/go.mod h1:7ksGy58fsaQkGQlY8WVoBFNyEPMGuJin1rfoPS4lBSQ= github.com/go-toolsmith/typep v1.1.0 h1:fIRYDyF+JywLfqzyhdiHzRop/GQDxxNhLGQ6gFUNHus= github.com/go-toolsmith/typep v1.1.0/go.mod h1:fVIw+7zjdsMxDA3ITWnH1yOiw1rnTQKCsF/sk2H/qig= -github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= -github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUWY= github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= -github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= -github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= +github.com/godoc-lint/godoc-lint v0.10.1 h1:ZPUVzlDtJfA+P688JfPJPkI/SuzcBr/753yGIk5bOPA= +github.com/godoc-lint/godoc-lint v0.10.1/go.mod h1:KleLcHu/CGSvkjUH2RvZyoK1MBC7pDQg4NxMYLcBBsw= +github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= +github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -227,22 +241,26 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golangci/asciicheck v0.5.0 h1:jczN/BorERZwK8oiFBOGvlGPknhvq0bjnysTj4nUfo0= +github.com/golangci/asciicheck v0.5.0/go.mod h1:5RMNAInbNFw2krqN6ibBxN/zfRFa9S6tA1nPdM0l8qQ= github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 h1:WUvBfQL6EW/40l6OmeSBYQJNSif4O11+bmWEz+C7FYw= github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32/go.mod h1:NUw9Zr2Sy7+HxzdjIULge71wI6yEg1lWQr7Evcu8K0E= -github.com/golangci/go-printf-func-name v0.1.0 h1:dVokQP+NMTO7jwO4bwsRwLWeudOVUPPyAKJuzv8pEJU= -github.com/golangci/go-printf-func-name v0.1.0/go.mod h1:wqhWFH5mUdJQhweRnldEywnR5021wTdZSNgwYceV14s= +github.com/golangci/go-printf-func-name v0.1.1 h1:hIYTFJqAGp1iwoIfsNTpoq1xZAarogrvjO9AfiW3B4U= +github.com/golangci/go-printf-func-name v0.1.1/go.mod h1:Es64MpWEZbh0UBtTAICOZiB+miW53w/K9Or/4QogJss= github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d h1:viFft9sS/dxoYY0aiOTsLKO2aZQAPT4nlQCsimGcSGE= github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d/go.mod h1:ivJ9QDg0XucIkmwhzCDsqcnxxlDStoTl89jDMIoNxKY= -github.com/golangci/golangci-lint/v2 v2.1.6 h1:LXqShFfAGM5BDzEOWD2SL1IzJAgUOqES/HRBsfKjI+w= -github.com/golangci/golangci-lint/v2 v2.1.6/go.mod h1:EPj+fgv4TeeBq3TcqaKZb3vkiV5dP4hHHKhXhEhzci8= +github.com/golangci/golangci-lint/v2 v2.6.2 h1:jkMSVv36JmyTENcEertckvimvjPcD5qxNM7W7qhECvI= +github.com/golangci/golangci-lint/v2 v2.6.2/go.mod h1:fSIMDiBt9kzdpnvvV7GO6iWzyv5uaeZ+iPor+2uRczE= github.com/golangci/golines v0.0.0-20250217134842-442fd0091d95 h1:AkK+w9FZBXlU/xUmBtSJN1+tAI4FIvy5WtnUnY8e4p8= github.com/golangci/golines v0.0.0-20250217134842-442fd0091d95/go.mod h1:k9mmcyWKSTMcPPvQUCfRWWQ9VHJ1U9Dc0R7kaXAgtnQ= -github.com/golangci/misspell v0.6.0 h1:JCle2HUTNWirNlDIAUO44hUsKhOFqGPoC4LZxlaSXDs= -github.com/golangci/misspell v0.6.0/go.mod h1:keMNyY6R9isGaSAu+4Q8NMBwMPkh15Gtc8UCVoDtAWo= -github.com/golangci/plugin-module-register v0.1.1 h1:TCmesur25LnyJkpsVrupv1Cdzo+2f7zX0H6Jkw1Ol6c= -github.com/golangci/plugin-module-register v0.1.1/go.mod h1:TTpqoB6KkwOJMV8u7+NyXMrkwwESJLOkfl9TxR1DGFc= +github.com/golangci/misspell v0.7.0 h1:4GOHr/T1lTW0hhR4tgaaV1WS/lJ+ncvYCoFKmqJsj0c= +github.com/golangci/misspell v0.7.0/go.mod h1:WZyyI2P3hxPY2UVHs3cS8YcllAeyfquQcKfdeE9AFVg= +github.com/golangci/plugin-module-register v0.1.2 h1:e5WM6PO6NIAEcij3B053CohVp3HIYbzSuP53UAYgOpg= +github.com/golangci/plugin-module-register v0.1.2/go.mod h1:1+QGTsKBvAIvPvoY/os+G5eoqxWn70HYDm2uvUyGuVw= github.com/golangci/revgrep v0.8.0 h1:EZBctwbVd0aMeRnNUsFogoyayvKHyxlV3CdUA46FX2s= github.com/golangci/revgrep v0.8.0/go.mod h1:U4R/s9dlXZsg8uJmaR1GrloUr14D7qDl8gi2iPXJH8k= +github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e h1:ai0EfmVYE2bRA5htgAG9r7s3tHsfjIhN98WshBTJ9jM= +github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e/go.mod h1:Vrn4B5oR9qRwM+f54koyeH3yzphlecwERs0el27Fr/s= github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e h1:gD6P7NEo7Eqtt0ssnqSJNNndxe69DOQ24A5h7+i3KpM= github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e/go.mod h1:h+wZwLjUTJnm/P2rwlbJdRPZXOzaT36/FwnPnY2inzc= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= @@ -257,7 +275,6 @@ github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= @@ -274,18 +291,17 @@ github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hf github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= -github.com/gordonklaus/ineffassign v0.1.0 h1:y2Gd/9I7MdY1oEIt+n+rowjBNDcLQq3RsH5hwJd0f9s= -github.com/gordonklaus/ineffassign v0.1.0/go.mod h1:Qcp2HIAYhR7mNUVSIxZww3Guk4it82ghYcEXIAk+QT0= +github.com/gordonklaus/ineffassign v0.2.0 h1:Uths4KnmwxNJNzq87fwQQDDnbNb7De00VOk9Nu0TySs= +github.com/gordonklaus/ineffassign v0.2.0/go.mod h1:TIpymnagPSexySzs7F9FnO1XFTy8IT3a59vmZp5Y9Lw= github.com/gostaticanalysis/analysisutil v0.7.1 h1:ZMCjoue3DtDWQ5WyU16YbjbQEQ3VuzwxALrpYd+HeKk= github.com/gostaticanalysis/analysisutil v0.7.1/go.mod h1:v21E3hY37WKMGSnbsw2S/ojApNWb6C1//mXO48CXbVc= -github.com/gostaticanalysis/comment v1.4.1/go.mod h1:ih6ZxzTHLdadaiSnF5WY3dxUoXfXAlTaRzuaNDlSado= github.com/gostaticanalysis/comment v1.4.2/go.mod h1:KLUTGDv6HOCotCH8h2erHKmpci2ZoR8VPu34YA2uzdM= github.com/gostaticanalysis/comment v1.5.0 h1:X82FLl+TswsUMpMh17srGRuKaaXprTaytmEpgnKIDu8= github.com/gostaticanalysis/comment v1.5.0/go.mod h1:V6eb3gpCv9GNVqb6amXzEUX3jXLVK/AdA+IrAMSqvEc= github.com/gostaticanalysis/forcetypeassert v0.2.0 h1:uSnWrrUEYDr86OCxWa4/Tp2jeYDlogZiZHzGkWFefTk= github.com/gostaticanalysis/forcetypeassert v0.2.0/go.mod h1:M5iPavzE9pPqWyeiVXSFghQjljW1+l/Uke3PXHS6ILY= -github.com/gostaticanalysis/nilerr v0.1.1 h1:ThE+hJP0fEp4zWLkWHWcRyI2Od0p7DlgYG3Uqrmrcpk= -github.com/gostaticanalysis/nilerr v0.1.1/go.mod h1:wZYb6YI5YAxxq0i1+VJbY0s2YONW0HU0GPE3+5PWN4A= +github.com/gostaticanalysis/nilerr v0.1.2 h1:S6nk8a9N8g062nsx63kUkF6AzbHGw7zzyHMcpu52xQU= +github.com/gostaticanalysis/nilerr v0.1.2/go.mod h1:A19UHhoY3y8ahoL7YKz6sdjDtduwTSI4CsymaC2htPA= github.com/gostaticanalysis/testutil v0.3.1-0.20210208050101-bfb5c8eec0e4/go.mod h1:D+FIZ+7OahH3ePw/izIEeH5I06eKs1IKI4Xr64/Am3M= github.com/hashicorp/go-immutable-radix/v2 v2.1.0 h1:CUW5RYIcysz+D3B+l1mDeXrQ7fUvGGCwJfdASSzbrfo= github.com/hashicorp/go-immutable-radix/v2 v2.1.0/go.mod h1:hgdqLXA4f6NIjRVisM1TJ9aOJVNRqKZj+xDGF6m7PBw= @@ -303,12 +319,12 @@ github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSo github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/jgautheron/goconst v1.8.1 h1:PPqCYp3K/xlOj5JmIe6O1Mj6r1DbkdbLtR3AJuZo414= -github.com/jgautheron/goconst v1.8.1/go.mod h1:A0oxgBCHy55NQn6sYpO7UdnA9p+h7cPtoOZUmvNIako= +github.com/jgautheron/goconst v1.8.2 h1:y0XF7X8CikZ93fSNT6WBTb/NElBu9IjaY7CCYQrCMX4= +github.com/jgautheron/goconst v1.8.2/go.mod h1:A0oxgBCHy55NQn6sYpO7UdnA9p+h7cPtoOZUmvNIako= github.com/jingyugao/rowserrcheck v1.1.1 h1:zibz55j/MJtLsjP1OF4bSdgXxwL1b+Vn7Tjzq7gFzUs= github.com/jingyugao/rowserrcheck v1.1.1/go.mod h1:4yvlZSDb3IyDTUZJUmpZfm2Hwok+Dtp+nu2qOq+er9c= -github.com/jjti/go-spancheck v0.6.4 h1:Tl7gQpYf4/TMU7AT84MN83/6PutY21Nb9fuQjFTpRRc= -github.com/jjti/go-spancheck v0.6.4/go.mod h1:yAEYdKJ2lRkDA8g7X+oKUHXOWVAXSBJRv04OhF+QUjk= +github.com/jjti/go-spancheck v0.6.5 h1:lmi7pKxa37oKYIMScialXUK6hP3iY5F1gu+mLBPgYB8= +github.com/jjti/go-spancheck v0.6.5/go.mod h1:aEogkeatBrbYsyW6y5TgDfihCulDYciL1B7rG2vSsrU= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= @@ -320,8 +336,8 @@ github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7V github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/julz/importas v0.2.0 h1:y+MJN/UdL63QbFJHws9BVC5RpA2iq0kpjrFajTGivjQ= github.com/julz/importas v0.2.0/go.mod h1:pThlt589EnCYtMnmhmRYY/qn9lCf/frPOK+WMx3xiJY= -github.com/karamaru-alpha/copyloopvar v1.2.1 h1:wmZaZYIjnJ0b5UoKDjUHrikcV0zuPyyxI4SVplLd2CI= -github.com/karamaru-alpha/copyloopvar v1.2.1/go.mod h1:nFmMlFNlClC2BPvNaHMdkirmTJxVCY0lhxBtlfOypMM= +github.com/karamaru-alpha/copyloopvar v1.2.2 h1:yfNQvP9YaGQR7VaWLYcfZUlRP2eo2vhExWKxD/fP6q0= +github.com/karamaru-alpha/copyloopvar v1.2.2/go.mod h1:oY4rGZqZ879JkJMtX3RRkcXRkmUvH0x35ykgaKgsgJY= github.com/kisielk/errcheck v1.9.0 h1:9xt1zI9EBfcYBvdU1nVrzMzzUPUtPKs9bVSIM3TAb3M= github.com/kisielk/errcheck v1.9.0/go.mod h1:kQxWMMVZgIkDq7U8xtG/n2juOjbLgZtedi0D+/VL/i8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= @@ -333,22 +349,22 @@ github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFB github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/kulti/thelper v0.6.3 h1:ElhKf+AlItIu+xGnI990no4cE2+XaSu1ULymV2Yulxs= -github.com/kulti/thelper v0.6.3/go.mod h1:DsqKShOvP40epevkFrvIwkCMNYxMeTNjdWL4dqWHZ6I= -github.com/kunwardeep/paralleltest v1.0.14 h1:wAkMoMeGX/kGfhQBPODT/BL8XhK23ol/nuQ3SwFaUw8= -github.com/kunwardeep/paralleltest v1.0.14/go.mod h1:di4moFqtfz3ToSKxhNjhOZL+696QtJGCFe132CbBLGk= +github.com/kulti/thelper v0.7.1 h1:fI8QITAoFVLx+y+vSyuLBP+rcVIB8jKooNSCT2EiI98= +github.com/kulti/thelper v0.7.1/go.mod h1:NsMjfQEy6sd+9Kfw8kCP61W1I0nerGSYSFnGaxQkcbs= +github.com/kunwardeep/paralleltest v1.0.15 h1:ZMk4Qt306tHIgKISHWFJAO1IDQJLc6uDyJMLyncOb6w= +github.com/kunwardeep/paralleltest v1.0.15/go.mod h1:di4moFqtfz3ToSKxhNjhOZL+696QtJGCFe132CbBLGk= github.com/lasiar/canonicalheader v1.1.2 h1:vZ5uqwvDbyJCnMhmFYimgMZnJMjwljN5VGY0VKbMXb4= github.com/lasiar/canonicalheader v1.1.2/go.mod h1:qJCeLFS0G/QlLQ506T+Fk/fWMa2VmBUiEI2cuMK4djI= -github.com/ldez/exptostd v0.4.3 h1:Ag1aGiq2epGePuRJhez2mzOpZ8sI9Gimcb4Sb3+pk9Y= -github.com/ldez/exptostd v0.4.3/go.mod h1:iZBRYaUmcW5jwCR3KROEZ1KivQQp6PHXbDPk9hqJKCQ= -github.com/ldez/gomoddirectives v0.6.1 h1:Z+PxGAY+217f/bSGjNZr/b2KTXcyYLgiWI6geMBN2Qc= -github.com/ldez/gomoddirectives v0.6.1/go.mod h1:cVBiu3AHR9V31em9u2kwfMKD43ayN5/XDgr+cdaFaKs= -github.com/ldez/grignotin v0.9.0 h1:MgOEmjZIVNn6p5wPaGp/0OKWyvq42KnzAt/DAb8O4Ow= -github.com/ldez/grignotin v0.9.0/go.mod h1:uaVTr0SoZ1KBii33c47O1M8Jp3OP3YDwhZCmzT9GHEk= -github.com/ldez/tagliatelle v0.7.1 h1:bTgKjjc2sQcsgPiT902+aadvMjCeMHrY7ly2XKFORIk= -github.com/ldez/tagliatelle v0.7.1/go.mod h1:3zjxUpsNB2aEZScWiZTHrAXOl1x25t3cRmzfK1mlo2I= -github.com/ldez/usetesting v0.4.3 h1:pJpN0x3fMupdTf/IapYjnkhiY1nSTN+pox1/GyBRw3k= -github.com/ldez/usetesting v0.4.3/go.mod h1:eEs46T3PpQ+9RgN9VjpY6qWdiw2/QmfiDeWmdZdrjIQ= +github.com/ldez/exptostd v0.4.5 h1:kv2ZGUVI6VwRfp/+bcQ6Nbx0ghFWcGIKInkG/oFn1aQ= +github.com/ldez/exptostd v0.4.5/go.mod h1:QRjHRMXJrCTIm9WxVNH6VW7oN7KrGSht69bIRwvdFsM= +github.com/ldez/gomoddirectives v0.7.1 h1:FaULkvUIG36hj6chpwa+FdCNGZBsD7/fO+p7CCsM6pE= +github.com/ldez/gomoddirectives v0.7.1/go.mod h1:auDNtakWJR1rC+YX7ar+HmveqXATBAyEK1KYpsIRW/8= +github.com/ldez/grignotin v0.10.1 h1:keYi9rYsgbvqAZGI1liek5c+jv9UUjbvdj3Tbn5fn4o= +github.com/ldez/grignotin v0.10.1/go.mod h1:UlDbXFCARrXbWGNGP3S5vsysNXAPhnSuBufpTEbwOas= +github.com/ldez/tagliatelle v0.7.2 h1:KuOlL70/fu9paxuxbeqlicJnCspCRjH0x8FW+NfgYUk= +github.com/ldez/tagliatelle v0.7.2/go.mod h1:PtGgm163ZplJfZMZ2sf5nhUT170rSuPgBimoyYtdaSI= +github.com/ldez/usetesting v0.5.0 h1:3/QtzZObBKLy1F4F8jLuKJiKBjjVFi1IavpoWbmqLwc= +github.com/ldez/usetesting v0.5.0/go.mod h1:Spnb4Qppf8JTuRgblLrEWb7IE6rDmUpGvxY3iRrzvDQ= github.com/leonklingele/grouper v1.1.2 h1:o1ARBDLOmmasUaNDesWqWCIFH3u7hoFlM84YrjT3mIY= github.com/leonklingele/grouper v1.1.2/go.mod h1:6D0M/HVkhs2yRKRFZUoGjeDy7EZTfFBE9gl4kjmIGkA= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= @@ -357,12 +373,14 @@ github.com/macabu/inamedparam v0.2.0 h1:VyPYpOc10nkhI2qeNUdh3Zket4fcZjEWe35poddB github.com/macabu/inamedparam v0.2.0/go.mod h1:+Pee9/YfGe5LJ62pYXqB89lJ+0k5bsR8Wgz/C0Zlq3U= github.com/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamhfG/Qzo= github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= -github.com/manuelarte/funcorder v0.2.1 h1:7QJsw3qhljoZ5rH0xapIvjw31EcQeFbF31/7kQ/xS34= -github.com/manuelarte/funcorder v0.2.1/go.mod h1:BQQ0yW57+PF9ZpjpeJDKOffEsQbxDFKW8F8zSMe/Zd0= -github.com/maratori/testableexamples v1.0.0 h1:dU5alXRrD8WKSjOUnmJZuzdxWOEQ57+7s93SLMxb2vI= -github.com/maratori/testableexamples v1.0.0/go.mod h1:4rhjL1n20TUTT4vdh3RDqSizKLyXp7K2u6HgraZCGzE= -github.com/maratori/testpackage v1.1.1 h1:S58XVV5AD7HADMmD0fNnziNHqKvSdDuEKdPD1rNTU04= -github.com/maratori/testpackage v1.1.1/go.mod h1:s4gRK/ym6AMrqpOa/kEbQTV4Q4jb7WeLZzVhVVVOQMc= +github.com/manuelarte/embeddedstructfieldcheck v0.4.0 h1:3mAIyaGRtjK6EO9E73JlXLtiy7ha80b2ZVGyacxgfww= +github.com/manuelarte/embeddedstructfieldcheck v0.4.0/go.mod h1:z8dFSyXqp+fC6NLDSljRJeNQJJDWnY7RoWFzV3PC6UM= +github.com/manuelarte/funcorder v0.5.0 h1:llMuHXXbg7tD0i/LNw8vGnkDTHFpTnWqKPI85Rknc+8= +github.com/manuelarte/funcorder v0.5.0/go.mod h1:Yt3CiUQthSBMBxjShjdXMexmzpP8YGvGLjrxJNkO2hA= +github.com/maratori/testableexamples v1.0.1 h1:HfOQXs+XgfeRBJ+Wz0XfH+FHnoY9TVqL6Fcevpzy4q8= +github.com/maratori/testableexamples v1.0.1/go.mod h1:XE2F/nQs7B9N08JgyRmdGjYVGqxWwClLPCGSQhXQSrQ= +github.com/maratori/testpackage v1.1.2 h1:ffDSh+AgqluCLMXhM19f/cpvQAKygKAJXFl9aUjmbqs= +github.com/maratori/testpackage v1.1.2/go.mod h1:8F24GdVDFW5Ew43Et02jamrVMNXLUNaOynhDssITGfc= github.com/matoous/godox v1.1.0 h1:W5mqwbyWrwZv6OQ5Z1a/DHGMOvXYCBP3+Ht7KMoJhq4= github.com/matoous/godox v1.1.0/go.mod h1:jgE/3fUXiTurkdHOLT5WEkThTSuE7yxHv5iWPa80afs= github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= @@ -370,13 +388,12 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= -github.com/mgechev/revive v1.9.0 h1:8LaA62XIKrb8lM6VsBSQ92slt/o92z5+hTw3CmrvSrM= -github.com/mgechev/revive v1.9.0/go.mod h1:LAPq3+MgOf7GcL5PlWIkHb0PT7XH4NuC2LdWymhb9Mo= +github.com/mgechev/revive v1.12.0 h1:Q+/kkbbwerrVYPv9d9efaPGmAO/NsxwW/nE6ahpQaCU= +github.com/mgechev/revive v1.12.0/go.mod h1:VXsY2LsTigk8XU9BpZauVLjVrhICMOV3k1lpB3CXrp8= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= @@ -398,10 +415,8 @@ github.com/nishanths/exhaustive v0.12.0 h1:vIY9sALmw6T/yxiASewa4TQcFsVYZQQRUQJhK github.com/nishanths/exhaustive v0.12.0/go.mod h1:mEZ95wPIZW+x8kC4TgC+9YCUgiST7ecevsVDTgc2obs= github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm/w98Vk= github.com/nishanths/predeclared v0.2.2/go.mod h1:RROzoN6TnGQupbC+lqggsOlcgysk3LMK/HI84Mp280c= -github.com/nunnatsa/ginkgolinter v0.19.1 h1:mjwbOlDQxZi9Cal+KfbEJTCz327OLNfwNvoZ70NJ+c4= -github.com/nunnatsa/ginkgolinter v0.19.1/go.mod h1:jkQ3naZDmxaZMXPWaS9rblH+i+GWXQCaS/JFIWcOH2s= -github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= -github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +github.com/nunnatsa/ginkgolinter v0.21.2 h1:khzWfm2/Br8ZemX8QM1pl72LwM+rMeW6VUbQ4rzh0Po= +github.com/nunnatsa/ginkgolinter v0.21.2/go.mod h1:GItSI5fw7mCGLPmkvGYrr1kEetZe7B593jcyOpyabsY= github.com/otiai10/copy v1.2.0/go.mod h1:rrF5dJ5F0t/EWSYODDu4j9/vEeYHMkc8jt0zJChqQWw= github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE= github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs= @@ -440,10 +455,10 @@ github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4O github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= github.com/prometheus/procfs v0.7.3 h1:4jVXhlkAyzOScmCkXBTOLRLTz8EeU+eyjrwB/EPq0VU= github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= -github.com/quasilyte/go-ruleguard v0.4.4 h1:53DncefIeLX3qEpjzlS1lyUmQoUEeOWPFWqaTJq9eAQ= -github.com/quasilyte/go-ruleguard v0.4.4/go.mod h1:Vl05zJ538vcEEwu16V/Hdu7IYZWyKSwIy4c88Ro1kRE= -github.com/quasilyte/go-ruleguard/dsl v0.3.22 h1:wd8zkOhSNr+I+8Qeciml08ivDt1pSXe60+5DqOpCjPE= -github.com/quasilyte/go-ruleguard/dsl v0.3.22/go.mod h1:KeCP03KrjuSO0H1kTuZQCWlQPulDV6YMIXmpQss17rU= +github.com/quasilyte/go-ruleguard v0.4.5 h1:AGY0tiOT5hJX9BTdx/xBdoCubQUAE2grkqY2lSwvZcA= +github.com/quasilyte/go-ruleguard v0.4.5/go.mod h1:Vl05zJ538vcEEwu16V/Hdu7IYZWyKSwIy4c88Ro1kRE= +github.com/quasilyte/go-ruleguard/dsl v0.3.23 h1:lxjt5B6ZCiBeeNO8/oQsegE6fLeCzuMRoVWSkXC4uvY= +github.com/quasilyte/go-ruleguard/dsl v0.3.23/go.mod h1:KeCP03KrjuSO0H1kTuZQCWlQPulDV6YMIXmpQss17rU= github.com/quasilyte/gogrep v0.5.0 h1:eTKODPXbI8ffJMN+W2aE0+oL0z/nh8/5eNdiO34SOAo= github.com/quasilyte/gogrep v0.5.0/go.mod h1:Cm9lpz9NZjEoL1tgZ2OgeUKPIxL1meE7eo60Z6Sk+Ng= github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 h1:TCg2WBOl980XxGFEZSS6KlBGIV0diGdySzxATTWoqaU= @@ -465,14 +480,14 @@ github.com/ryanrolds/sqlclosecheck v0.5.1 h1:dibWW826u0P8jNLsLN+En7+RqWWTYrjCB9f github.com/ryanrolds/sqlclosecheck v0.5.1/go.mod h1:2g3dUjoS6AL4huFdv6wn55WpLIDjY7ZgUR4J8HOO/XQ= github.com/sanposhiho/wastedassign/v2 v2.1.0 h1:crurBF7fJKIORrV85u9UUpePDYGWnwvv3+A96WvwXT0= github.com/sanposhiho/wastedassign/v2 v2.1.0/go.mod h1:+oSmSC+9bQ+VUAxA66nBb0Z7N8CK7mscKTDYC6aIek4= -github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 h1:PKK9DyHxif4LZo+uQSgXNqs0jj5+xZwwfKHgph2lxBw= -github.com/santhosh-tekuri/jsonschema/v6 v6.0.1/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/sashamelentyev/interfacebloat v1.1.0 h1:xdRdJp0irL086OyW1H/RTZTr1h/tMEOsumirXcOJqAw= github.com/sashamelentyev/interfacebloat v1.1.0/go.mod h1:+Y9yU5YdTkrNvoX0xHc84dxiN1iBi9+G8zZIhPVoNjQ= -github.com/sashamelentyev/usestdlibvars v1.28.0 h1:jZnudE2zKCtYlGzLVreNp5pmCdOxXUzwsMDBkR21cyQ= -github.com/sashamelentyev/usestdlibvars v1.28.0/go.mod h1:9nl0jgOfHKWNFS43Ojw0i7aRoS4j6EBye3YBhmAIRF8= -github.com/securego/gosec/v2 v2.22.3 h1:mRrCNmRF2NgZp4RJ8oJ6yPJ7G4x6OCiAXHd8x4trLRc= -github.com/securego/gosec/v2 v2.22.3/go.mod h1:42M9Xs0v1WseinaB/BmNGO8AVqG8vRfhC2686ACY48k= +github.com/sashamelentyev/usestdlibvars v1.29.0 h1:8J0MoRrw4/NAXtjQqTHrbW9NN+3iMf7Knkq057v4XOQ= +github.com/sashamelentyev/usestdlibvars v1.29.0/go.mod h1:8PpnjHMk5VdeWlVb4wCdrB8PNbLqZ3wBZTZWkrpZZL8= +github.com/securego/gosec/v2 v2.22.10 h1:ntbBqdWXnu46DUOXn+R2SvPo3PiJCDugTCgTW2g4tQg= +github.com/securego/gosec/v2 v2.22.10/go.mod h1:9UNjK3tLpv/w2b0+7r82byV43wCJDNtEDQMeS+H/g2w= github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk= github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041/go.mod h1:N5mDOmsrJOB+vfqUK+7DmDyjhSLIIBnXo9lvZJj3MWQ= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= @@ -482,21 +497,22 @@ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sivchari/containedctx v1.0.3 h1:x+etemjbsh2fB5ewm5FeLNi5bUjK0V8n0RB+Wwfd0XE= github.com/sivchari/containedctx v1.0.3/go.mod h1:c1RDvCbnJLtH4lLcYD/GqwiBSSf4F5Qk0xld2rBqzJ4= -github.com/sonatard/noctx v0.1.0 h1:JjqOc2WN16ISWAjAk8M5ej0RfExEXtkEyExl2hLW+OM= -github.com/sonatard/noctx v0.1.0/go.mod h1:0RvBxqY8D4j9cTTTWE8ylt2vqj2EPI8fHmrxHdsaZ2c= +github.com/sonatard/noctx v0.4.0 h1:7MC/5Gg4SQ4lhLYR6mvOP6mQVSxCrdyiExo7atBs27o= +github.com/sonatard/noctx v0.4.0/go.mod h1:64XdbzFb18XL4LporKXp8poqZtPKbCrqQ402CV+kJas= github.com/sourcegraph/go-diff v0.7.0 h1:9uLlrd5T46OXs5qpp8L/MTltk0zikUGi0sNNyCpA8G0= github.com/sourcegraph/go-diff v0.7.0/go.mod h1:iBszgVvyxdc8SFZ7gm69go2KDdt3ag071iBaWPF6cjs= github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA= github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo= github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w= github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU= -github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= -github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= +github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk= github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= -github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.12.0 h1:CZ7eSOd3kZoaYDLbXnmzgQI5RlciuXBMA+18HwHRfZQ= github.com/spf13/viper v1.12.0/go.mod h1:b6COn30jlNxbm/V2IqWiNWkJ+vZNiMNksliPCiuKtSI= github.com/ssgreg/nlreturn/v2 v2.2.1 h1:X4XDI7jstt3ySqGU86YGAURbxw3oTDPK9sPEi6YEwQ0= @@ -505,28 +521,20 @@ github.com/stbenjam/no-sprintf-host-port v0.2.0 h1:i8pxvGrt1+4G0czLr/WnmyH7zbZ8B github.com/stbenjam/no-sprintf-host-port v0.2.0/go.mod h1:eL0bQ9PasS0hsyTyfTjjG+E80QIyPnBVQbYZyv20Jfk= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.4.1 h1:jyEFiXpy21Wm81FBN71l9VoMMV8H8jG+qIK3GCpY6Qs= github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= -github.com/tdakkota/asciicheck v0.4.1 h1:bm0tbcmi0jezRA2b5kg4ozmMuGAFotKI3RZfrhfovg8= -github.com/tdakkota/asciicheck v0.4.1/go.mod h1:0k7M3rCfRXb0Z6bwgvkEIMleKH3kXNz9UqJ9Xuqopr8= github.com/tenntenn/modver v1.0.1/go.mod h1:bePIyQPb7UeioSRkw3Q0XeMhYZSMx9B8ePqg6SAMGH0= github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3/go.mod h1:ON8b8w4BN/kE1EOhwT0o+d62W65a6aPw1nouo9LMgyY= -github.com/tetafro/godot v1.5.1 h1:PZnjCol4+FqaEzvZg5+O8IY2P3hfY9JzRBNPv1pEDS4= -github.com/tetafro/godot v1.5.1/go.mod h1:cCdPtEndkmqqrhiCfkmxDodMQJ/f3L1BCNskCUZdTwk= +github.com/tetafro/godot v1.5.4 h1:u1ww+gqpRLiIA16yF2PV1CV1n/X3zhyezbNXC3E14Sg= +github.com/tetafro/godot v1.5.4/go.mod h1:eOkMrVQurDui411nBY2FA05EYH01r14LuWY/NrVDVcU= github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 h1:9LPGD+jzxMlnk5r6+hJnar67cgpDIz/iyD+rfl5r2Vk= github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67/go.mod h1:mkjARE7Yr8qU23YcGMSALbIxTQ9r9QBVahQOBRfU460= github.com/timonwong/loggercheck v0.11.0 h1:jdaMpYBl+Uq9mWPXv1r8jc5fC3gyXx4/WGwTnnNKn4M= @@ -541,8 +549,8 @@ github.com/ultraware/whitespace v0.2.0 h1:TYowo2m9Nfj1baEQBjuHzvMRbp19i+RCcRYrSW github.com/ultraware/whitespace v0.2.0/go.mod h1:XcP1RLD81eV4BW8UhQlpaR+SDc2givTvyI8a586WjW8= github.com/uudashr/gocognit v1.2.0 h1:3BU9aMr1xbhPlvJLSydKwdLN3tEUUrzPSSM8S4hDYRA= github.com/uudashr/gocognit v1.2.0/go.mod h1:k/DdKPI6XBZO1q7HgoV2juESI2/Ofj9AcHPZhBBdrTU= -github.com/uudashr/iface v1.3.1 h1:bA51vmVx1UIhiIsQFSNq6GZ6VPTk3WNMZgRiCe9R29U= -github.com/uudashr/iface v1.3.1/go.mod h1:4QvspiRd3JLPAEXBQ9AiZpLbJlrWWgRChOKDJEuQTdg= +github.com/uudashr/iface v1.4.1 h1:J16Xl1wyNX9ofhpHmQ9h9gk5rnv2A6lX/2+APLTo0zU= +github.com/uudashr/iface v1.4.1/go.mod h1:pbeBPlbuU2qkNDn0mmfrxP2X+wjPMIQAy+r1MBXSXtg= github.com/xen0n/gosmopolitan v1.3.0 h1:zAZI1zefvo7gcpbCOrPSHJZJYA9ZgLfJqtKzZ5pHqQM= github.com/xen0n/gosmopolitan v1.3.0/go.mod h1:rckfr5T6o4lBtM1ga7mLGKZmLxswUoH1zxHgNXOsEt4= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= @@ -562,25 +570,25 @@ github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= gitlab.com/bosi/decorder v0.4.2 h1:qbQaV3zgwnBZ4zPMhGLW4KZe7A7NwxEhJx39R3shffo= gitlab.com/bosi/decorder v0.4.2/go.mod h1:muuhHoaJkA9QLcYHq4Mj8FJUwDZ+EirSHRiaTcTf6T8= -go-simpler.org/musttag v0.13.1 h1:lw2sJyu7S1X8lc8zWUAdH42y+afdcCnHhWpnkWvd6vU= -go-simpler.org/musttag v0.13.1/go.mod h1:8r450ehpMLQgvpb6sg+hV5Ur47eH6olp/3yEanfG97k= -go-simpler.org/sloglint v0.11.0 h1:JlR1X4jkbeaffiyjLtymeqmGDKBDO1ikC6rjiuFAOco= -go-simpler.org/sloglint v0.11.0/go.mod h1:CFDO8R1i77dlciGfPEPvYke2ZMx4eyGiEIWkyeW2Pvw= -go.augendre.info/fatcontext v0.8.0 h1:2dfk6CQbDGeu1YocF59Za5Pia7ULeAM6friJ3LP7lmk= -go.augendre.info/fatcontext v0.8.0/go.mod h1:oVJfMgwngMsHO+KB2MdgzcO+RvtNdiCEOlWvSFtax/s= +go-simpler.org/musttag v0.14.0 h1:XGySZATqQYSEV3/YTy+iX+aofbZZllJaqwFWs+RTtSo= +go-simpler.org/musttag v0.14.0/go.mod h1:uP8EymctQjJ4Z1kUnjX0u2l60WfUdQxCwSNKzE1JEOE= +go-simpler.org/sloglint v0.11.1 h1:xRbPepLT/MHPTCA6TS/wNfZrDzkGvCCqUv4Bdwc3H7s= +go-simpler.org/sloglint v0.11.1/go.mod h1:2PowwiCOK8mjiF+0KGifVOT8ZsCNiFzvfyJeJOIt8MQ= +go.augendre.info/arangolint v0.3.1 h1:n2E6p8f+zfXSFLa2e2WqFPp4bfvcuRdd50y6cT65pSo= +go.augendre.info/arangolint v0.3.1/go.mod h1:6ZKzEzIZuBQwoSvlKT+qpUfIbBfFCE5gbAoTg0/117g= +go.augendre.info/fatcontext v0.9.0 h1:Gt5jGD4Zcj8CDMVzjOJITlSb9cEch54hjRRlN3qDojE= +go.augendre.info/fatcontext v0.9.0/go.mod h1:L94brOAT1OOUNue6ph/2HnwxoNlds9aXDF2FcUntbNw= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= -go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= -go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4= -go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= -go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60= -go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= @@ -603,8 +611,8 @@ golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMk golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk= golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/exp/typeparams v0.0.0-20230203172020-98cc5a0785f9/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= -golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac h1:TSSpLIG4v+p0rPv1pNOQtl1I8knsO4S9trOxNMOLVP4= -golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= +golang.org/x/exp/typeparams v0.0.0-20251023183803-a4bb9ffd2546 h1:HDjDiATsGqvuqvkDvgJjD1IgPrVekcSXVVE21JwvzGE= +golang.org/x/exp/typeparams v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:4Mzdyp/6jzw9auFDJ3OMF5qksa7UvPnzKqTVGcb04ms= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -629,13 +637,11 @@ golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= -golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -670,9 +676,7 @@ golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96b golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= @@ -697,8 +701,8 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= -golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= -golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -745,19 +749,16 @@ golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= -golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= @@ -768,13 +769,11 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -810,7 +809,6 @@ golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapK golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200324003944-a576cf524670/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= golang.org/x/tools v0.0.0-20200329025819-fd4102a86c65/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= @@ -820,22 +818,17 @@ golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roY golang.org/x/tools v0.0.0-20200724022722-7017fd6b1305/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200820010801-b793a1359eac/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20201023174141-c8cfbd0f21e6/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.1-0.20210205202024-ef80cdb6ec6d/go.mod h1:9bzcO0MWcOuT0tm1iBGzDVPshzfwoVvREIui8C+MHqU= golang.org/x/tools v0.1.1-0.20210302220138-2ac05c832e1a/go.mod h1:9bzcO0MWcOuT0tm1iBGzDVPshzfwoVvREIui8C+MHqU= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= -golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU= -golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -915,8 +908,8 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -943,10 +936,10 @@ honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9 honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI= honnef.co/go/tools v0.6.1/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4= -mvdan.cc/gofumpt v0.8.0 h1:nZUCeC2ViFaerTcYKstMmfysj6uhQrA2vJe+2vwGU6k= -mvdan.cc/gofumpt v0.8.0/go.mod h1:vEYnSzyGPmjvFkqJWtXkh79UwPWP9/HMxQdGEXZHjpg= -mvdan.cc/unparam v0.0.0-20250301125049-0df0534333a4 h1:WjUu4yQoT5BHT1w8Zu56SP8367OuBV5jvo+4Ulppyf8= -mvdan.cc/unparam v0.0.0-20250301125049-0df0534333a4/go.mod h1:rthT7OuvRbaGcd5ginj6dA2oLE7YNlta9qhBNNdCaLE= +mvdan.cc/gofumpt v0.9.2 h1:zsEMWL8SVKGHNztrx6uZrXdp7AX8r421Vvp23sz7ik4= +mvdan.cc/gofumpt v0.9.2/go.mod h1:iB7Hn+ai8lPvofHd9ZFGVg2GOr8sBUw1QUWjNbmIL/s= +mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15 h1:ssMzja7PDPJV8FStj7hq9IKiuiKhgz9ErWw+m68e7DI= +mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15/go.mod h1:4M5MMXl2kW6fivUT6yRGpLLPNfuGtU2Z0cPvFquGDYU= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= -rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= \ No newline at end of file +rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/.bingo/variables.env b/.bingo/variables.env index 7b01dee0b..fc3a980e0 100644 --- a/.bingo/variables.env +++ b/.bingo/variables.env @@ -18,7 +18,7 @@ CRD_REF_DOCS="${GOBIN}/crd-ref-docs-v0.2.0" GOJQ="${GOBIN}/gojq-v0.12.17" -GOLANGCI_LINT="${GOBIN}/golangci-lint-v2.1.6" +GOLANGCI_LINT="${GOBIN}/golangci-lint-v2.6.2" GORELEASER="${GOBIN}/goreleaser-v1.26.2" diff --git a/.claude/commands/test-profile.md b/.claude/commands/test-profile.md new file mode 100644 index 000000000..f6f4aa6a4 --- /dev/null +++ b/.claude/commands/test-profile.md @@ -0,0 +1,98 @@ +--- +description: Profile memory and CPU usage during e2e tests and analyze results +--- + +# Test Profiling + +Profile memory and CPU usage during e2e tests using the Go-based `bin/test-profile` tool. + +## Available Commands + +### /test-profile start [name] +Start background profiling daemon. Collects heap/CPU profiles from operator-controller and catalogd every 10s. +- Auto-stops after 3 consecutive collection failures (e.g., cluster teardown) +- Use with any test command: `make test-e2e`, `make test-experimental-e2e`, etc. +- Follow with `/test-profile stop` to generate analysis + +**Example:** +```bash +/test-profile start baseline +make test-e2e +/test-profile stop +``` + +### /test-profile stop +Stop profiling daemon and generate analysis report. Cleans up port-forwards and empty profiles. + +### /test-profile run [name] [test-target] +Automated workflow: start test, profile until completion, analyze. +- Default test-target: `test-e2e` +- Other targets: `test-experimental-e2e`, `test-upgrade-e2e`, etc. + +**Example:** +```bash +/test-profile run baseline test-e2e +``` + +### /test-profile analyze [name] +Analyze existing profiles in `test-profiles/[name]/`. Generates markdown report with: +- Memory/CPU growth patterns +- Top allocators +- OpenAPI, JSON, and cache hotspots + +### /test-profile compare [baseline] [optimized] +Compare two test runs. Outputs to `test-profiles/comparisons/[baseline]-vs-[optimized].md` + +### /test-profile collect +One-time snapshot of heap/CPU profiles from running pods. Saves to `test-profiles/manual/` + +## Implementation Steps + +When executing commands, I will: + +1. **Build tool**: `make build-test-profiler` (builds to `bin/test-profile`) +2. **Execute command**: `./bin/test-profile [command] [args]` +3. **For start/stop workflow**: Monitor daemon logs, handle errors gracefully +4. **For run command**: Start test in background, monitor progress, analyze on completion +5. **For analysis**: Present key findings from generated markdown reports + +## Configuration + +Environment variables (defaults shown): +```bash +TEST_PROFILE_COMPONENTS="operator-controller:olmv1-system:operator-controller-controller-manager:6060;catalogd:olmv1-system:catalogd-controller-manager:6060" +TEST_PROFILE_INTERVAL=10 # seconds between collections +TEST_PROFILE_CPU_DURATION=10 # CPU profiling duration +TEST_PROFILE_MODE=both # both|heap|cpu +TEST_PROFILE_DIR=./test-profiles +TEST_PROFILE_TEST_TARGET=test-e2e +``` + +## Output Structure + +``` +test-profiles/ +├── [name]/ +│ ├── operator-controller/ +│ │ ├── heap*.pprof +│ │ └── cpu*.pprof +│ ├── catalogd/ +│ │ ├── heap*.pprof +│ │ └── cpu*.pprof +│ ├── profiler.log +│ └── analysis.md +└── comparisons/ + └── [name1]-vs-[name2].md +``` + +## Tool Location + +- Source: `hack/tools/test-profiling/` (Go-based CLI) +- Binary: `bin/test-profile` +- Make targets: `make build-test-profiler`, `make start-profiling/[name]`, `make stop-profiling` + +## Requirements + +- kubectl access to cluster +- go tool pprof (for analysis) +- Go version minimum from `hack/tools/test-profiling/go.mod` (for building) diff --git a/.gitignore b/.gitignore index 2c3fb2359..b77a0dc0b 100644 --- a/.gitignore +++ b/.gitignore @@ -38,8 +38,13 @@ vendor/ \#*\# .\#* -# AI temp files files -.claude/ +# AI temp/local files +.claude/settings.local.json +.claude/history/ +.claude/cache/ +.claude/logs/ +.claude/.session* +.claude/*.log # documentation website asset folder site diff --git a/Makefile b/Makefile index 758f51bcb..388471065 100644 --- a/Makefile +++ b/Makefile @@ -178,9 +178,13 @@ generate: $(CONTROLLER_GEN) #EXHELP Generate code containing DeepCopy, DeepCopyI $(CONTROLLER_GEN) --load-build-tags=$(GO_BUILD_TAGS) object:headerFile="hack/boilerplate.go.txt" paths="./..." .PHONY: verify -verify: k8s-pin kind-verify-versions fmt generate manifests update-tls-profiles crd-ref-docs #HELP Verify all generated code is up-to-date. Runs k8s-pin instead of just tidy. +verify: k8s-pin kind-verify-versions fmt generate manifests update-tls-profiles crd-ref-docs verify-bingo #HELP Verify all generated code is up-to-date. Runs k8s-pin instead of just tidy. git diff --exit-code +.PHONY: verify-bingo +verify-bingo: $(BINGO) + $(BINGO) get -v -t 15 + .PHONY: fix-lint fix-lint: $(GOLANGCI_LINT) #EXHELP Fix lint issues $(GOLANGCI_LINT) run --fix --build-tags $(GO_BUILD_TAGS) $(GOLANGCI_LINT_ARGS) @@ -385,7 +389,7 @@ kind-clean: $(KIND) #EXHELP Delete the kind cluster. $(KIND) delete cluster --name $(KIND_CLUSTER_NAME) .PHONY: kind-verify-versions -kind-verify-versions: +kind-verify-versions: $(KIND) env K8S_VERSION=v$(K8S_VERSION) KIND=$(KIND) GOBIN=$(GOBIN) hack/tools/validate_kindest_node.sh diff --git a/api/v1/clusterextensionrevision_types.go b/api/v1/clusterextensionrevision_types.go index 13ac4ce2a..e048e1b54 100644 --- a/api/v1/clusterextensionrevision_types.go +++ b/api/v1/clusterextensionrevision_types.go @@ -19,7 +19,6 @@ package v1 import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/types" ) const ( @@ -40,6 +39,7 @@ const ( ClusterExtensionRevisionReasonIncomplete = "Incomplete" ClusterExtensionRevisionReasonProgressing = "Progressing" ClusterExtensionRevisionReasonArchived = "Archived" + ClusterExtensionRevisionReasonMigrated = "Migrated" ) // ClusterExtensionRevisionSpec defines the desired state of ClusterExtensionRevision. @@ -66,10 +66,6 @@ type ClusterExtensionRevisionSpec struct { // +listMapKey=name // +optional Phases []ClusterExtensionRevisionPhase `json:"phases,omitempty"` - // Previous references previous revisions that objects can be adopted from. - // - // +kubebuilder:validation:XValidation:rule="self == oldSelf", message="previous is immutable" - Previous []ClusterExtensionRevisionPrevious `json:"previous,omitempty"` } // ClusterExtensionRevisionLifecycleState specifies the lifecycle state of the ClusterExtensionRevision. @@ -108,6 +104,7 @@ type ClusterExtensionRevisionObject struct { // already existing on the cluster or even owned by another controller. // // +kubebuilder:default="Prevent" + // +kubebuilder:validation:Enum=Prevent;IfNoController;None // +optional CollisionProtection CollisionProtection `json:"collisionProtection,omitempty"` } @@ -129,13 +126,6 @@ const ( CollisionProtectionNone CollisionProtection = "None" ) -type ClusterExtensionRevisionPrevious struct { - // +kubebuilder:validation:Required - Name string `json:"name"` - // +kubebuilder:validation:Required - UID types.UID `json:"uid"` -} - // ClusterExtensionRevisionStatus defines the observed state of a ClusterExtensionRevision. type ClusterExtensionRevisionStatus struct { // +listType=map diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go index e13f1532b..cc27ec68f 100644 --- a/api/v1/zz_generated.deepcopy.go +++ b/api/v1/zz_generated.deepcopy.go @@ -436,21 +436,6 @@ func (in *ClusterExtensionRevisionPhase) DeepCopy() *ClusterExtensionRevisionPha return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ClusterExtensionRevisionPrevious) DeepCopyInto(out *ClusterExtensionRevisionPrevious) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterExtensionRevisionPrevious. -func (in *ClusterExtensionRevisionPrevious) DeepCopy() *ClusterExtensionRevisionPrevious { - if in == nil { - return nil - } - out := new(ClusterExtensionRevisionPrevious) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ClusterExtensionRevisionSpec) DeepCopyInto(out *ClusterExtensionRevisionSpec) { *out = *in @@ -461,11 +446,6 @@ func (in *ClusterExtensionRevisionSpec) DeepCopyInto(out *ClusterExtensionRevisi (*in)[i].DeepCopyInto(&(*out)[i]) } } - if in.Previous != nil { - in, out := &in.Previous, &out.Previous - *out = make([]ClusterExtensionRevisionPrevious, len(*in)) - copy(*out, *in) - } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterExtensionRevisionSpec. diff --git a/cmd/catalogd/main.go b/cmd/catalogd/main.go index af2463e2c..36f7b1675 100644 --- a/cmd/catalogd/main.go +++ b/cmd/catalogd/main.go @@ -59,7 +59,6 @@ import ( "github.com/operator-framework/operator-controller/internal/catalogd/storage" "github.com/operator-framework/operator-controller/internal/catalogd/webhook" sharedcontrollers "github.com/operator-framework/operator-controller/internal/shared/controllers" - cacheutil "github.com/operator-framework/operator-controller/internal/shared/util/cache" fsutil "github.com/operator-framework/operator-controller/internal/shared/util/fs" httputil "github.com/operator-framework/operator-controller/internal/shared/util/http" imageutil "github.com/operator-framework/operator-controller/internal/shared/util/image" @@ -255,8 +254,6 @@ func run(ctx context.Context) error { cacheOptions := crcache.Options{ ByObject: map[client.Object]crcache.ByObject{}, - // Memory optimization: strip managed fields and large annotations from cached objects - DefaultTransform: cacheutil.StripManagedFieldsAndAnnotations(), } saKey, err := sautil.GetServiceAccount() diff --git a/cmd/operator-controller/main.go b/cmd/operator-controller/main.go index 20d55bc3c..7425c7b66 100644 --- a/cmd/operator-controller/main.go +++ b/cmd/operator-controller/main.go @@ -78,7 +78,6 @@ import ( "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/render/registryv1" "github.com/operator-framework/operator-controller/internal/operator-controller/scheme" sharedcontrollers "github.com/operator-framework/operator-controller/internal/shared/controllers" - cacheutil "github.com/operator-framework/operator-controller/internal/shared/util/cache" fsutil "github.com/operator-framework/operator-controller/internal/shared/util/fs" httputil "github.com/operator-framework/operator-controller/internal/shared/util/http" imageutil "github.com/operator-framework/operator-controller/internal/shared/util/image" @@ -233,8 +232,6 @@ func run() error { cfg.systemNamespace: {LabelSelector: k8slabels.Everything()}, }, DefaultLabelSelector: k8slabels.Nothing(), - // Memory optimization: strip managed fields and large annotations from cached objects - DefaultTransform: cacheutil.StripAnnotations(), } if features.OperatorControllerFeatureGate.Enabled(features.BoxcutterRuntime) { @@ -586,6 +583,7 @@ func setupBoxcutter( ceReconciler.RevisionStatesGetter = &controllers.BoxcutterRevisionStatesGetter{Reader: mgr.GetClient()} ceReconciler.StorageMigrator = &applier.BoxcutterStorageMigrator{ Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), ActionClientGetter: acg, RevisionGenerator: rg, } diff --git a/commitchecker.yaml b/commitchecker.yaml index ed15ab897..a2779500a 100644 --- a/commitchecker.yaml +++ b/commitchecker.yaml @@ -1,4 +1,4 @@ -expectedMergeBase: c06f27fa84371eab49b56da50ef68088251c873b +expectedMergeBase: 1355ff732209e279fe15bb52fc3b772479e1081b upstreamBranch: main upstreamOrg: operator-framework upstreamRepo: operator-controller diff --git a/go.mod b/go.mod index 767250f00..c52e95e85 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.0 github.com/google/go-cmp v0.7.0 github.com/google/go-containerregistry v0.20.6 - github.com/google/renameio/v2 v2.0.0 + github.com/google/renameio/v2 v2.0.1 github.com/gorilla/handlers v1.5.2 github.com/klauspost/compress v1.18.1 github.com/opencontainers/go-digest v1.0.0 @@ -22,7 +22,8 @@ require ( github.com/operator-framework/helm-operator-plugins v0.8.0 github.com/operator-framework/operator-registry v1.61.0 github.com/prometheus/client_golang v1.23.2 - github.com/prometheus/common v0.67.2 + github.com/prometheus/common v0.67.3 + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 github.com/spf13/cobra v1.10.1 github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 @@ -192,7 +193,6 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/rubenv/sql-migrate v1.8.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect - github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/secure-systems-lab/go-securesystemslib v0.9.1 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/sigstore/fulcio v1.7.1 // indirect diff --git a/go.sum b/go.sum index c3832f635..ec3903580 100644 --- a/go.sum +++ b/go.sum @@ -248,8 +248,8 @@ github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d h1:KJIErDwbSHjnp/SGzE5ed8Aol7JsKiI5X7yWKAtzhM0= github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U= -github.com/google/renameio/v2 v2.0.0 h1:UifI23ZTGY8Tt29JbYFiuyIU3eX+RNFtUwefq9qAhxg= -github.com/google/renameio/v2 v2.0.0/go.mod h1:BtmJXm5YlszgC+TD4HOEEUFgkJP3nLxehU6hfe7jRt4= +github.com/google/renameio/v2 v2.0.1 h1:HyOM6qd9gF9sf15AvhbptGHUnaLTpEI9akAFFU3VyW0= +github.com/google/renameio/v2 v2.0.1/go.mod h1:BtmJXm5YlszgC+TD4HOEEUFgkJP3nLxehU6hfe7jRt4= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -418,8 +418,8 @@ github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UH github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8= -github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= +github.com/prometheus/common v0.67.3 h1:shd26MlnwTw5jksTDhC7rTQIteBxy+ZZDr3t7F2xN2Q= +github.com/prometheus/common v0.67.3/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= github.com/redis/go-redis/extra/rediscmd/v9 v9.10.0 h1:uTiEyEyfLhkw678n6EulHVto8AkcXVr8zUcBJNZ0ark= diff --git a/hack/tools/validate_kindest_node.sh b/hack/tools/validate_kindest_node.sh index c1fdcc313..f00632bcc 100755 --- a/hack/tools/validate_kindest_node.sh +++ b/hack/tools/validate_kindest_node.sh @@ -4,8 +4,24 @@ # Extract the version of kind, by removing the "${GOBIN}/kind-" prefix KIND=${KIND#${GOBIN}/kind-} -# Get the version of the image -KIND_VER=$(curl -L -s https://github.com/kubernetes-sigs/kind/raw/refs/tags/${KIND}/pkg/apis/config/defaults/image.go | grep -Eo 'v[0-9]+\.[0-9]+') +GOMODCACHE=$(go env GOMODCACHE) + +REGEX='v[0-9]+\.[0-9]+' + +# Get the version of the image from the local kind build +if [ -d "${GOMODCACHE}" ]; then + KIND_VER=$(grep -Eo "${REGEX}" ${GOMODCACHE}/sigs.k8s.io/kind@${KIND}/pkg/apis/config/defaults/image.go) +fi + +# Get the version of the image from github +if [ -z "${KIND_VER}" ]; then + KIND_VER=$(curl -L -s https://github.com/kubernetes-sigs/kind/raw/refs/tags/${KIND}/pkg/apis/config/defaults/image.go | grep -Eo "${REGEX}") +fi + +if [ -z "${KIND_VER}" ]; then + echo "Unable to determine kindest/node version" + exit 1 +fi # Compare the versions if [ "${KIND_VER}" != "${K8S_VERSION}" ]; then diff --git a/helm/olmv1/base/operator-controller/crd/experimental/olm.operatorframework.io_clusterextensionrevisions.yaml b/helm/olmv1/base/operator-controller/crd/experimental/olm.operatorframework.io_clusterextensionrevisions.yaml index 5004c8c6f..b25e57903 100644 --- a/helm/olmv1/base/operator-controller/crd/experimental/olm.operatorframework.io_clusterextensionrevisions.yaml +++ b/helm/olmv1/base/operator-controller/crd/experimental/olm.operatorframework.io_clusterextensionrevisions.yaml @@ -87,6 +87,10 @@ spec: description: |- CollisionProtection controls whether OLM can adopt and modify objects already existing on the cluster or even owned by another controller. + enum: + - Prevent + - IfNoController + - None type: string object: type: object @@ -107,27 +111,6 @@ spec: x-kubernetes-validations: - message: phases is immutable rule: self == oldSelf || oldSelf.size() == 0 - previous: - description: Previous references previous revisions that objects can - be adopted from. - items: - properties: - name: - type: string - uid: - description: |- - UID is a type that holds unique ID values, including UUIDs. Because we - don't ONLY use UUIDs, this is an alias to string. Being a type captures - intent and helps make sure that UIDs and names do not get conflated. - type: string - required: - - name - - uid - type: object - type: array - x-kubernetes-validations: - - message: previous is immutable - rule: self == oldSelf revision: description: |- Revision is a sequence number representing a specific revision of the ClusterExtension instance. diff --git a/internal/operator-controller/applier/boxcutter.go b/internal/operator-controller/applier/boxcutter.go index 1914b80e8..6abcd0c43 100644 --- a/internal/operator-controller/applier/boxcutter.go +++ b/internal/operator-controller/applier/boxcutter.go @@ -26,13 +26,11 @@ import ( helmclient "github.com/operator-framework/helm-operator-plugins/pkg/client" ocv1 "github.com/operator-framework/operator-controller/api/v1" - "github.com/operator-framework/operator-controller/internal/operator-controller/controllers" "github.com/operator-framework/operator-controller/internal/operator-controller/labels" - "github.com/operator-framework/operator-controller/internal/shared/util/cache" ) const ( - ClusterExtensionRevisionPreviousLimit = 5 + ClusterExtensionRevisionRetentionLimit = 5 ) type ClusterExtensionRevisionGenerator interface { @@ -68,9 +66,6 @@ func (r *SimpleRevisionGenerator) GenerateRevisionFromHelmRelease( maps.Copy(labels, objectLabels) obj.SetLabels(labels) - // Memory optimization: strip large annotations - // Note: ApplyStripTransform never returns an error in practice - _ = cache.ApplyStripAnnotationsTransform(&obj) sanitizedUnstructured(ctx, &obj) objs = append(objs, ocv1.ClusterExtensionRevisionObject{ @@ -122,10 +117,6 @@ func (r *SimpleRevisionGenerator) GenerateRevision( unstr := unstructured.Unstructured{Object: unstrObj} unstr.SetGroupVersionKind(gvk) - // Memory optimization: strip large annotations - if err := cache.ApplyStripAnnotationsTransform(&unstr); err != nil { - return nil, err - } sanitizedUnstructured(ctx, &unstr) objs = append(objs, ocv1.ClusterExtensionRevisionObject{ @@ -191,30 +182,41 @@ func (r *SimpleRevisionGenerator) buildClusterExtensionRevision( ObjectMeta: metav1.ObjectMeta{ Annotations: annotations, Labels: map[string]string{ - controllers.ClusterExtensionRevisionOwnerLabel: ext.Name, + labels.OwnerNameKey: ext.Name, }, }, Spec: ocv1.ClusterExtensionRevisionSpec{ - Phases: PhaseSort(objects), + // Explicitly set LifecycleState to Active. While the CRD has a default, + // being explicit here ensures all code paths are clear and doesn't rely + // on API server defaulting behavior. + LifecycleState: ocv1.ClusterExtensionRevisionLifecycleStateActive, + Phases: PhaseSort(objects), }, } } +// BoxcutterStorageMigrator migrates ClusterExtensions from Helm-based storage to +// ClusterExtensionRevision storage, enabling upgrades from older operator-controller versions. type BoxcutterStorageMigrator struct { ActionClientGetter helmclient.ActionClientGetter RevisionGenerator ClusterExtensionRevisionGenerator Client boxcutterStorageMigratorClient + Scheme *runtime.Scheme } type boxcutterStorageMigratorClient interface { List(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error + Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error Create(ctx context.Context, obj client.Object, opts ...client.CreateOption) error + Status() client.StatusWriter } +// Migrate creates a ClusterExtensionRevision from an existing Helm release if no revisions exist yet. +// The migration is idempotent and skipped if revisions already exist or no Helm release is found. func (m *BoxcutterStorageMigrator) Migrate(ctx context.Context, ext *ocv1.ClusterExtension, objectLabels map[string]string) error { existingRevisionList := ocv1.ClusterExtensionRevisionList{} if err := m.Client.List(ctx, &existingRevisionList, client.MatchingLabels{ - controllers.ClusterExtensionRevisionOwnerLabel: ext.Name, + labels.OwnerNameKey: ext.Name, }); err != nil { return fmt.Errorf("listing ClusterExtensionRevisions before attempting migration: %w", err) } @@ -242,9 +244,34 @@ func (m *BoxcutterStorageMigrator) Migrate(ctx context.Context, ext *ocv1.Cluste return err } + // Set ownerReference for proper garbage collection when the ClusterExtension is deleted. + if err := controllerutil.SetControllerReference(ext, rev, m.Scheme); err != nil { + return fmt.Errorf("set ownerref: %w", err) + } + if err := m.Client.Create(ctx, rev); err != nil { return err } + + // Re-fetch to get server-managed fields like Generation + if err := m.Client.Get(ctx, client.ObjectKeyFromObject(rev), rev); err != nil { + return fmt.Errorf("getting created revision: %w", err) + } + + // Set Available=Unknown so the revision controller will verify cluster state through probes. + // During migration, ClusterExtension will briefly show as not installed until verification completes. + meta.SetStatusCondition(&rev.Status.Conditions, metav1.Condition{ + Type: ocv1.ClusterExtensionRevisionTypeAvailable, + Status: metav1.ConditionUnknown, + Reason: ocv1.ClusterExtensionRevisionReasonMigrated, + Message: "Migrated from Helm storage, awaiting cluster state verification", + ObservedGeneration: rev.Generation, + }) + + if err := m.Client.Status().Update(ctx, rev); err != nil { + return fmt.Errorf("updating migrated revision status: %w", err) + } + return nil } @@ -304,7 +331,6 @@ func (bc *Boxcutter) apply(ctx context.Context, contentFS fs.FS, ext *ocv1.Clust if len(existingRevisions) > 0 { // try first to update the current revision. currentRevision = &existingRevisions[len(existingRevisions)-1] - desiredRevision.Spec.Previous = currentRevision.Spec.Previous desiredRevision.Spec.Revision = currentRevision.Spec.Revision desiredRevision.Name = currentRevision.Name @@ -354,8 +380,8 @@ func (bc *Boxcutter) apply(ctx context.Context, contentFS fs.FS, ext *ocv1.Clust desiredRevision.Name = fmt.Sprintf("%s-%d", ext.Name, revisionNumber) desiredRevision.Spec.Revision = revisionNumber - if err = bc.setPreviousRevisions(ctx, desiredRevision, prevRevisions); err != nil { - return false, "", fmt.Errorf("garbage collecting old Revisions: %w", err) + if err = bc.garbageCollectOldRevisions(ctx, prevRevisions); err != nil { + return false, "", fmt.Errorf("garbage collecting old revisions: %w", err) } if err := bc.createOrUpdate(ctx, desiredRevision); err != nil { @@ -380,28 +406,21 @@ func (bc *Boxcutter) apply(ctx context.Context, contentFS fs.FS, ext *ocv1.Clust return true, "", nil } -// setPreviousRevisions populates spec.previous of latestRevision, trimming the list of previous _archived_ revisions down to -// ClusterExtensionRevisionPreviousLimit or to the first _active_ revision and deletes trimmed revisions from the cluster. -// NOTE: revisionList must be sorted in chronographical order, from oldest to latest. -func (bc *Boxcutter) setPreviousRevisions(ctx context.Context, latestRevision *ocv1.ClusterExtensionRevision, revisionList []ocv1.ClusterExtensionRevision) error { - // Pre-allocate with capacity limit to reduce allocations - trimmedPrevious := make([]ocv1.ClusterExtensionRevisionPrevious, 0, ClusterExtensionRevisionPreviousLimit) +// garbageCollectOldRevisions deletes archived revisions beyond ClusterExtensionRevisionRetentionLimit. +// Active revisions are never deleted. revisionList must be sorted oldest to newest. +func (bc *Boxcutter) garbageCollectOldRevisions(ctx context.Context, revisionList []ocv1.ClusterExtensionRevision) error { for index, r := range revisionList { - if index < len(revisionList)-ClusterExtensionRevisionPreviousLimit && r.Spec.LifecycleState == ocv1.ClusterExtensionRevisionLifecycleStateArchived { - // Delete oldest CREs from the cluster and list to reach ClusterExtensionRevisionPreviousLimit or latest active revision + // Only delete archived revisions that are beyond the limit + if index < len(revisionList)-ClusterExtensionRevisionRetentionLimit && r.Spec.LifecycleState == ocv1.ClusterExtensionRevisionLifecycleStateArchived { if err := bc.Client.Delete(ctx, &ocv1.ClusterExtensionRevision{ ObjectMeta: metav1.ObjectMeta{ Name: r.Name, }, }); err != nil && !apierrors.IsNotFound(err) { - return fmt.Errorf("deleting previous archived Revision: %w", err) + return fmt.Errorf("deleting archived revision: %w", err) } - } else { - // All revisions within the limit or still active are preserved - trimmedPrevious = append(trimmedPrevious, ocv1.ClusterExtensionRevisionPrevious{Name: r.Name, UID: r.GetUID()}) } } - latestRevision.Spec.Previous = trimmedPrevious return nil } @@ -409,7 +428,7 @@ func (bc *Boxcutter) setPreviousRevisions(ctx context.Context, latestRevision *o func (bc *Boxcutter) getExistingRevisions(ctx context.Context, extName string) ([]ocv1.ClusterExtensionRevision, error) { existingRevisionList := &ocv1.ClusterExtensionRevisionList{} if err := bc.Client.List(ctx, existingRevisionList, client.MatchingLabels{ - controllers.ClusterExtensionRevisionOwnerLabel: extName, + labels.OwnerNameKey: extName, }); err != nil { return nil, fmt.Errorf("listing revisions: %w", err) } diff --git a/internal/operator-controller/applier/boxcutter_test.go b/internal/operator-controller/applier/boxcutter_test.go index ad30bf2c1..8cae359f5 100644 --- a/internal/operator-controller/applier/boxcutter_test.go +++ b/internal/operator-controller/applier/boxcutter_test.go @@ -20,7 +20,6 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/validation/field" k8scheme "k8s.io/client-go/kubernetes/scheme" "sigs.k8s.io/controller-runtime/pkg/client" @@ -29,7 +28,6 @@ import ( ocv1 "github.com/operator-framework/operator-controller/api/v1" "github.com/operator-framework/operator-controller/internal/operator-controller/applier" - "github.com/operator-framework/operator-controller/internal/operator-controller/controllers" "github.com/operator-framework/operator-controller/internal/operator-controller/labels" ) @@ -87,11 +85,12 @@ func Test_SimpleRevisionGenerator_GenerateRevisionFromHelmRelease(t *testing.T) "olm.operatorframework.io/package-name": "my-package", }, Labels: map[string]string{ - "olm.operatorframework.io/owner": "test-123", + labels.OwnerNameKey: "test-123", }, }, Spec: ocv1.ClusterExtensionRevisionSpec{ - Revision: 1, + LifecycleState: ocv1.ClusterExtensionRevisionLifecycleStateActive, + Revision: 1, Phases: []ocv1.ClusterExtensionRevisionPhase{ { Name: "deploy", @@ -178,9 +177,9 @@ func Test_SimpleRevisionGenerator_GenerateRevision(t *testing.T) { rev, err := b.GenerateRevision(t.Context(), fstest.MapFS{}, ext, map[string]string{}, map[string]string{}) require.NoError(t, err) - t.Log("by checking the olm.operatorframework.io/owner label is set to the name of the ClusterExtension") + t.Log("by checking the olm.operatorframework.io/owner-name label is set to the name of the ClusterExtension") require.Equal(t, map[string]string{ - controllers.ClusterExtensionRevisionOwnerLabel: "test-extension", + labels.OwnerNameKey: "test-extension", }, rev.Labels) t.Log("by checking the revision number is 0") require.Equal(t, int64(0), rev.Spec.Revision) @@ -344,7 +343,7 @@ func TestBoxcutter_Apply(t *testing.T) { Name: "test-ext-1", UID: "rev-uid-1", Labels: map[string]string{ - controllers.ClusterExtensionRevisionOwnerLabel: ext.Name, + labels.OwnerNameKey: ext.Name, }, }, Spec: ocv1.ClusterExtensionRevisionSpec{ @@ -402,7 +401,7 @@ func TestBoxcutter_Apply(t *testing.T) { ObjectMeta: metav1.ObjectMeta{ Annotations: revisionAnnotations, Labels: map[string]string{ - controllers.ClusterExtensionRevisionOwnerLabel: ext.Name, + labels.OwnerNameKey: ext.Name, }, }, Spec: ocv1.ClusterExtensionRevisionSpec{ @@ -430,7 +429,7 @@ func TestBoxcutter_Apply(t *testing.T) { }, validate: func(t *testing.T, c client.Client) { revList := &ocv1.ClusterExtensionRevisionList{} - err := c.List(t.Context(), revList, client.MatchingLabels{controllers.ClusterExtensionRevisionOwnerLabel: ext.Name}) + err := c.List(t.Context(), revList, client.MatchingLabels{labels.OwnerNameKey: ext.Name}) require.NoError(t, err) require.Len(t, revList.Items, 1) @@ -450,7 +449,7 @@ func TestBoxcutter_Apply(t *testing.T) { ObjectMeta: metav1.ObjectMeta{ Annotations: revisionAnnotations, Labels: map[string]string{ - controllers.ClusterExtensionRevisionOwnerLabel: ext.Name, + labels.OwnerNameKey: ext.Name, }, }, Spec: ocv1.ClusterExtensionRevisionSpec{ @@ -481,7 +480,7 @@ func TestBoxcutter_Apply(t *testing.T) { }, validate: func(t *testing.T, c client.Client) { revList := &ocv1.ClusterExtensionRevisionList{} - err := c.List(context.Background(), revList, client.MatchingLabels{controllers.ClusterExtensionRevisionOwnerLabel: ext.Name}) + err := c.List(context.Background(), revList, client.MatchingLabels{labels.OwnerNameKey: ext.Name}) require.NoError(t, err) // No new revision should be created require.Len(t, revList.Items, 1) @@ -496,7 +495,7 @@ func TestBoxcutter_Apply(t *testing.T) { ObjectMeta: metav1.ObjectMeta{ Annotations: revisionAnnotations, Labels: map[string]string{ - controllers.ClusterExtensionRevisionOwnerLabel: ext.Name, + labels.OwnerNameKey: ext.Name, }, }, Spec: ocv1.ClusterExtensionRevisionSpec{ @@ -528,7 +527,7 @@ func TestBoxcutter_Apply(t *testing.T) { }, validate: func(t *testing.T, c client.Client) { revList := &ocv1.ClusterExtensionRevisionList{} - err := c.List(context.Background(), revList, client.MatchingLabels{controllers.ClusterExtensionRevisionOwnerLabel: ext.Name}) + err := c.List(context.Background(), revList, client.MatchingLabels{labels.OwnerNameKey: ext.Name}) require.NoError(t, err) require.Len(t, revList.Items, 2) @@ -544,9 +543,6 @@ func TestBoxcutter_Apply(t *testing.T) { assert.Equal(t, "test-ext-2", newRev.Name) assert.Equal(t, int64(2), newRev.Spec.Revision) - require.Len(t, newRev.Spec.Previous, 1) - assert.Equal(t, "test-ext-1", newRev.Spec.Previous[0].Name) - assert.Equal(t, types.UID("rev-uid-1"), newRev.Spec.Previous[0].UID) }, }, { @@ -560,7 +556,7 @@ func TestBoxcutter_Apply(t *testing.T) { validate: func(t *testing.T, c client.Client) { // Ensure no revisions were created revList := &ocv1.ClusterExtensionRevisionList{} - err := c.List(context.Background(), revList, client.MatchingLabels{controllers.ClusterExtensionRevisionOwnerLabel: ext.Name}) + err := c.List(context.Background(), revList, client.MatchingLabels{labels.OwnerNameKey: ext.Name}) require.NoError(t, err) assert.Empty(t, revList.Items) }, @@ -573,7 +569,7 @@ func TestBoxcutter_Apply(t *testing.T) { ObjectMeta: metav1.ObjectMeta{ Annotations: revisionAnnotations, Labels: map[string]string{ - controllers.ClusterExtensionRevisionOwnerLabel: ext.Name, + labels.OwnerNameKey: ext.Name, }, }, Spec: ocv1.ClusterExtensionRevisionSpec{}, @@ -585,7 +581,7 @@ func TestBoxcutter_Apply(t *testing.T) { ObjectMeta: metav1.ObjectMeta{ Name: "rev-1", Labels: map[string]string{ - controllers.ClusterExtensionRevisionOwnerLabel: ext.Name, + labels.OwnerNameKey: ext.Name, }, }, Spec: ocv1.ClusterExtensionRevisionSpec{ @@ -597,7 +593,7 @@ func TestBoxcutter_Apply(t *testing.T) { ObjectMeta: metav1.ObjectMeta{ Name: "rev-2", Labels: map[string]string{ - controllers.ClusterExtensionRevisionOwnerLabel: ext.Name, + labels.OwnerNameKey: ext.Name, }, }, Spec: ocv1.ClusterExtensionRevisionSpec{ @@ -609,7 +605,7 @@ func TestBoxcutter_Apply(t *testing.T) { ObjectMeta: metav1.ObjectMeta{ Name: "rev-3", Labels: map[string]string{ - controllers.ClusterExtensionRevisionOwnerLabel: ext.Name, + labels.OwnerNameKey: ext.Name, }, }, Spec: ocv1.ClusterExtensionRevisionSpec{ @@ -621,7 +617,7 @@ func TestBoxcutter_Apply(t *testing.T) { ObjectMeta: metav1.ObjectMeta{ Name: "rev-4", Labels: map[string]string{ - controllers.ClusterExtensionRevisionOwnerLabel: ext.Name, + labels.OwnerNameKey: ext.Name, }, }, Spec: ocv1.ClusterExtensionRevisionSpec{ @@ -633,7 +629,7 @@ func TestBoxcutter_Apply(t *testing.T) { ObjectMeta: metav1.ObjectMeta{ Name: "rev-5", Labels: map[string]string{ - controllers.ClusterExtensionRevisionOwnerLabel: ext.Name, + labels.OwnerNameKey: ext.Name, }, }, Spec: ocv1.ClusterExtensionRevisionSpec{ @@ -645,7 +641,7 @@ func TestBoxcutter_Apply(t *testing.T) { ObjectMeta: metav1.ObjectMeta{ Name: "rev-6", Labels: map[string]string{ - controllers.ClusterExtensionRevisionOwnerLabel: ext.Name, + labels.OwnerNameKey: ext.Name, }, }, Spec: ocv1.ClusterExtensionRevisionSpec{ @@ -661,10 +657,12 @@ func TestBoxcutter_Apply(t *testing.T) { require.Error(t, err) assert.True(t, apierrors.IsNotFound(err)) - latest := &ocv1.ClusterExtensionRevision{} - err = c.Get(t.Context(), client.ObjectKey{Name: "test-ext-7"}, latest) + // Verify garbage collection: should only keep the limit + 1 (current) revisions + revList := &ocv1.ClusterExtensionRevisionList{} + err = c.List(t.Context(), revList) require.NoError(t, err) - assert.Len(t, latest.Spec.Previous, applier.ClusterExtensionRevisionPreviousLimit) + // Should have ClusterExtensionRevisionRetentionLimit (5) + current (1) = 6 revisions max + assert.LessOrEqual(t, len(revList.Items), applier.ClusterExtensionRevisionRetentionLimit+1) }, }, { @@ -675,7 +673,7 @@ func TestBoxcutter_Apply(t *testing.T) { ObjectMeta: metav1.ObjectMeta{ Annotations: revisionAnnotations, Labels: map[string]string{ - controllers.ClusterExtensionRevisionOwnerLabel: ext.Name, + labels.OwnerNameKey: ext.Name, }, }, Spec: ocv1.ClusterExtensionRevisionSpec{}, @@ -687,7 +685,7 @@ func TestBoxcutter_Apply(t *testing.T) { ObjectMeta: metav1.ObjectMeta{ Name: "rev-1", Labels: map[string]string{ - controllers.ClusterExtensionRevisionOwnerLabel: ext.Name, + labels.OwnerNameKey: ext.Name, }, }, Spec: ocv1.ClusterExtensionRevisionSpec{ @@ -699,7 +697,7 @@ func TestBoxcutter_Apply(t *testing.T) { ObjectMeta: metav1.ObjectMeta{ Name: "rev-2", Labels: map[string]string{ - controllers.ClusterExtensionRevisionOwnerLabel: ext.Name, + labels.OwnerNameKey: ext.Name, }, }, Spec: ocv1.ClusterExtensionRevisionSpec{ @@ -712,7 +710,7 @@ func TestBoxcutter_Apply(t *testing.T) { ObjectMeta: metav1.ObjectMeta{ Name: "rev-3", Labels: map[string]string{ - controllers.ClusterExtensionRevisionOwnerLabel: ext.Name, + labels.OwnerNameKey: ext.Name, }, }, Spec: ocv1.ClusterExtensionRevisionSpec{ @@ -724,7 +722,7 @@ func TestBoxcutter_Apply(t *testing.T) { ObjectMeta: metav1.ObjectMeta{ Name: "rev-4", Labels: map[string]string{ - controllers.ClusterExtensionRevisionOwnerLabel: ext.Name, + labels.OwnerNameKey: ext.Name, }, }, Spec: ocv1.ClusterExtensionRevisionSpec{ @@ -737,7 +735,7 @@ func TestBoxcutter_Apply(t *testing.T) { ObjectMeta: metav1.ObjectMeta{ Name: "rev-5", Labels: map[string]string{ - controllers.ClusterExtensionRevisionOwnerLabel: ext.Name, + labels.OwnerNameKey: ext.Name, }, }, Spec: ocv1.ClusterExtensionRevisionSpec{ @@ -749,7 +747,7 @@ func TestBoxcutter_Apply(t *testing.T) { ObjectMeta: metav1.ObjectMeta{ Name: "rev-6", Labels: map[string]string{ - controllers.ClusterExtensionRevisionOwnerLabel: ext.Name, + labels.OwnerNameKey: ext.Name, }, }, Spec: ocv1.ClusterExtensionRevisionSpec{ @@ -761,7 +759,7 @@ func TestBoxcutter_Apply(t *testing.T) { ObjectMeta: metav1.ObjectMeta{ Name: "rev-7", Labels: map[string]string{ - controllers.ClusterExtensionRevisionOwnerLabel: ext.Name, + labels.OwnerNameKey: ext.Name, }, }, Spec: ocv1.ClusterExtensionRevisionSpec{ @@ -781,15 +779,97 @@ func TestBoxcutter_Apply(t *testing.T) { err = c.Get(t.Context(), client.ObjectKey{Name: "rev-2"}, rev2) require.NoError(t, err) + // Verify active revisions are kept even if beyond the limit rev4 := &ocv1.ClusterExtensionRevision{} err = c.Get(t.Context(), client.ObjectKey{Name: "rev-4"}, rev4) + require.NoError(t, err, "active revision 4 should still exist even though it's beyond the limit") + }, + }, + { + name: "annotation-only update (same phases, different annotations)", + mockBuilder: &mockBundleRevisionBuilder{ + makeRevisionFunc: func(ctx context.Context, bundleFS fs.FS, ext *ocv1.ClusterExtension, objectLabels, revisionAnnotations map[string]string) (*ocv1.ClusterExtensionRevision, error) { + return &ocv1.ClusterExtensionRevision{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: revisionAnnotations, + Labels: map[string]string{ + labels.OwnerNameKey: ext.Name, + }, + }, + Spec: ocv1.ClusterExtensionRevisionSpec{ + Phases: []ocv1.ClusterExtensionRevisionPhase{ + { + Name: string(applier.PhaseDeploy), + Objects: []ocv1.ClusterExtensionRevisionObject{ + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "test-cm", + }, + }, + }, + }, + }, + }, + }, + }, + }, nil + }, + }, + existingObjs: []client.Object{ + ext, + &ocv1.ClusterExtensionRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ext-1", + Annotations: map[string]string{ + labels.BundleVersionKey: "1.0.0", + labels.PackageNameKey: "test-package", + }, + Labels: map[string]string{ + labels.OwnerNameKey: ext.Name, + }, + }, + Spec: ocv1.ClusterExtensionRevisionSpec{ + Revision: 1, + Phases: []ocv1.ClusterExtensionRevisionPhase{ + { + Name: string(applier.PhaseDeploy), + Objects: []ocv1.ClusterExtensionRevisionObject{ + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "test-cm", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + validate: func(t *testing.T, c client.Client) { + revList := &ocv1.ClusterExtensionRevisionList{} + err := c.List(context.Background(), revList, client.MatchingLabels{labels.OwnerNameKey: ext.Name}) require.NoError(t, err) + // Should still be only 1 revision (in-place update, not new revision) + require.Len(t, revList.Items, 1) - latest := &ocv1.ClusterExtensionRevision{} - err = c.Get(t.Context(), client.ObjectKey{Name: "test-ext-8"}, latest) - require.NoError(t, err) - assert.Len(t, latest.Spec.Previous, 6) - assert.Contains(t, latest.Spec.Previous, ocv1.ClusterExtensionRevisionPrevious{Name: "rev-4"}) + rev := revList.Items[0] + assert.Equal(t, "test-ext-1", rev.Name) + assert.Equal(t, int64(1), rev.Spec.Revision) + // Verify annotations were updated + assert.Equal(t, "1.0.1", rev.Annotations[labels.BundleVersionKey]) + assert.Equal(t, "test-package", rev.Annotations[labels.PackageNameKey]) + // Verify owner label is still present + assert.Equal(t, ext.Name, rev.Labels[labels.OwnerNameKey]) }, }, } @@ -814,7 +894,15 @@ func TestBoxcutter_Apply(t *testing.T) { testFS := fstest.MapFS{} // Execute - installSucceeded, installStatus, err := boxcutter.Apply(t.Context(), testFS, ext, nil, nil) + revisionAnnotations := map[string]string{} + if tc.name == "annotation-only update (same phases, different annotations)" { + // For annotation-only update test, pass NEW annotations + revisionAnnotations = map[string]string{ + labels.BundleVersionKey: "1.0.1", + labels.PackageNameKey: "test-package", + } + } + installSucceeded, installStatus, err := boxcutter.Apply(t.Context(), testFS, ext, nil, revisionAnnotations) // Assert if tc.expectedErr != "" { @@ -842,6 +930,9 @@ func TestBoxcutter_Apply(t *testing.T) { func TestBoxcutterStorageMigrator(t *testing.T) { t.Run("creates revision", func(t *testing.T) { + testScheme := runtime.NewScheme() + require.NoError(t, ocv1.AddToScheme(testScheme)) + brb := &mockBundleRevisionBuilder{} mag := &mockActionGetter{} client := &clientMock{} @@ -849,6 +940,7 @@ func TestBoxcutterStorageMigrator(t *testing.T) { RevisionGenerator: brb, ActionClientGetter: mag, Client: client, + Scheme: testScheme, } ext := &ocv1.ClusterExtension{ @@ -861,6 +953,22 @@ func TestBoxcutterStorageMigrator(t *testing.T) { client. On("Create", mock.Anything, mock.AnythingOfType("*v1.ClusterExtensionRevision"), mock.Anything). Once(). + Run(func(args mock.Arguments) { + // Simulate real Kubernetes behavior: Create() populates server-managed fields + rev := args.Get(1).(*ocv1.ClusterExtensionRevision) + rev.Generation = 1 + rev.ResourceVersion = "1" + }). + Return(nil) + client. + On("Get", mock.Anything, mock.Anything, mock.AnythingOfType("*v1.ClusterExtensionRevision"), mock.Anything). + Once(). + Run(func(args mock.Arguments) { + // Simulate Get() returning the created revision with server-managed fields + rev := args.Get(2).(*ocv1.ClusterExtensionRevision) + rev.Generation = 1 + rev.ResourceVersion = "1" + }). Return(nil) err := sm.Migrate(t.Context(), ext, map[string]string{"my-label": "my-value"}) @@ -870,6 +978,9 @@ func TestBoxcutterStorageMigrator(t *testing.T) { }) t.Run("does not create revision when revisions exist", func(t *testing.T) { + testScheme := runtime.NewScheme() + require.NoError(t, ocv1.AddToScheme(testScheme)) + brb := &mockBundleRevisionBuilder{} mag := &mockActionGetter{} client := &clientMock{} @@ -877,6 +988,7 @@ func TestBoxcutterStorageMigrator(t *testing.T) { RevisionGenerator: brb, ActionClientGetter: mag, Client: client, + Scheme: testScheme, } ext := &ocv1.ClusterExtension{ @@ -900,6 +1012,9 @@ func TestBoxcutterStorageMigrator(t *testing.T) { }) t.Run("does not create revision when no helm release", func(t *testing.T) { + testScheme := runtime.NewScheme() + require.NoError(t, ocv1.AddToScheme(testScheme)) + brb := &mockBundleRevisionBuilder{} mag := &mockActionGetter{ getClientErr: driver.ErrReleaseNotFound, @@ -909,6 +1024,7 @@ func TestBoxcutterStorageMigrator(t *testing.T) { RevisionGenerator: brb, ActionClientGetter: mag, Client: client, + Scheme: testScheme, } ext := &ocv1.ClusterExtension{ @@ -940,7 +1056,15 @@ func (m *mockBundleRevisionBuilder) GenerateRevisionFromHelmRelease( helmRelease *release.Release, ext *ocv1.ClusterExtension, objectLabels map[string]string, ) (*ocv1.ClusterExtensionRevision, error) { - return nil, nil + return &ocv1.ClusterExtensionRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-revision", + Labels: map[string]string{ + labels.OwnerNameKey: ext.Name, + }, + }, + Spec: ocv1.ClusterExtensionRevisionSpec{}, + }, nil } type clientMock struct { @@ -952,7 +1076,33 @@ func (m *clientMock) List(ctx context.Context, list client.ObjectList, opts ...c return args.Error(0) } +func (m *clientMock) Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + args := m.Called(ctx, key, obj, opts) + return args.Error(0) +} + func (m *clientMock) Create(ctx context.Context, obj client.Object, opts ...client.CreateOption) error { args := m.Called(ctx, obj, opts) return args.Error(0) } + +func (m *clientMock) Status() client.StatusWriter { + return &statusWriterMock{mock: &m.Mock} +} + +type statusWriterMock struct { + mock *mock.Mock +} + +func (s *statusWriterMock) Update(ctx context.Context, obj client.Object, opts ...client.SubResourceUpdateOption) error { + // Status updates are expected during migration - return success by default + return nil +} + +func (s *statusWriterMock) Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.SubResourcePatchOption) error { + return nil +} + +func (s *statusWriterMock) Create(ctx context.Context, obj client.Object, subResource client.Object, opts ...client.SubResourceCreateOption) error { + return nil +} diff --git a/internal/operator-controller/applier/phase.go b/internal/operator-controller/applier/phase.go index 9ae31db6a..6baa396cf 100644 --- a/internal/operator-controller/applier/phase.go +++ b/internal/operator-controller/applier/phase.go @@ -1,6 +1,9 @@ package applier import ( + "cmp" + "slices" + "k8s.io/apimachinery/pkg/runtime/schema" ocv1 "github.com/operator-framework/operator-controller/api/v1" @@ -111,6 +114,23 @@ func init() { } } +// Sort objects within the phase deterministically by Group, Version, Kind, Namespace, Name +// to ensure consistent ordering regardless of input order. This is critical for +// Helm-to-Boxcutter migration where the same resources may come from different sources +// (Helm release manifest vs bundle manifest) and need to produce identical phases. +func compareClusterExtensionRevisionObjects(a, b ocv1.ClusterExtensionRevisionObject) int { + aGVK := a.Object.GroupVersionKind() + bGVK := b.Object.GroupVersionKind() + + return cmp.Or( + cmp.Compare(aGVK.Group, bGVK.Group), + cmp.Compare(aGVK.Version, bGVK.Version), + cmp.Compare(aGVK.Kind, bGVK.Kind), + cmp.Compare(a.Object.GetNamespace(), b.Object.GetNamespace()), + cmp.Compare(a.Object.GetName(), b.Object.GetName()), + ) +} + // PhaseSort takes an unsorted list of objects and organizes them into sorted phases. // Each phase will be applied in order according to DefaultPhaseOrder. Objects // within a single phase are applied simultaneously. @@ -125,6 +145,9 @@ func PhaseSort(unsortedObjs []ocv1.ClusterExtensionRevisionObject) []ocv1.Cluste for _, phaseName := range defaultPhaseOrder { if objs, ok := phaseMap[phaseName]; ok { + // Sort objects within the phase deterministically + slices.SortFunc(objs, compareClusterExtensionRevisionObjects) + phasesSorted = append(phasesSorted, ocv1.ClusterExtensionRevisionPhase{ Name: string(phaseName), Objects: objs, diff --git a/internal/operator-controller/applier/phase_test.go b/internal/operator-controller/applier/phase_test.go index 3f2d85d0b..6c0fe8fb3 100644 --- a/internal/operator-controller/applier/phase_test.go +++ b/internal/operator-controller/applier/phase_test.go @@ -259,6 +259,14 @@ func Test_PhaseSort(t *testing.T) { { Name: string(applier.PhaseDeploy), Objects: []v1.ClusterExtensionRevisionObject{ + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + }, + }, + }, { Object: unstructured.Unstructured{ Object: map[string]interface{}{ @@ -267,11 +275,64 @@ func Test_PhaseSort(t *testing.T) { }, }, }, + }, + }, + }, + }, + { + name: "no objects", + objs: []v1.ClusterExtensionRevisionObject{}, + want: []v1.ClusterExtensionRevisionPhase{}, + }, + { + name: "sort by group within same phase", + objs: []v1.ClusterExtensionRevisionObject{ + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]interface{}{ + "name": "test", + }, + }, + }, + }, + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "test", + }, + }, + }, + }, + }, + want: []v1.ClusterExtensionRevisionPhase{ + { + Name: string(applier.PhaseDeploy), + Objects: []v1.ClusterExtensionRevisionObject{ { Object: unstructured.Unstructured{ Object: map[string]interface{}{ "apiVersion": "v1", "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "test", + }, + }, + }, + }, + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]interface{}{ + "name": "test", + }, }, }, }, @@ -280,9 +341,542 @@ func Test_PhaseSort(t *testing.T) { }, }, { - name: "no objects", - objs: []v1.ClusterExtensionRevisionObject{}, - want: []v1.ClusterExtensionRevisionPhase{}, + name: "sort by version within same group and phase", + objs: []v1.ClusterExtensionRevisionObject{ + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "batch/v1", + "kind": "Job", + "metadata": map[string]interface{}{ + "name": "test", + }, + }, + }, + }, + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "batch/v1beta1", + "kind": "CronJob", + "metadata": map[string]interface{}{ + "name": "test", + }, + }, + }, + }, + }, + want: []v1.ClusterExtensionRevisionPhase{ + { + Name: string(applier.PhaseDeploy), + Objects: []v1.ClusterExtensionRevisionObject{ + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "batch/v1", + "kind": "Job", + "metadata": map[string]interface{}{ + "name": "test", + }, + }, + }, + }, + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "batch/v1beta1", + "kind": "CronJob", + "metadata": map[string]interface{}{ + "name": "test", + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "sort by kind within same group, version, and phase", + objs: []v1.ClusterExtensionRevisionObject{ + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Service", + "metadata": map[string]interface{}{ + "name": "test", + }, + }, + }, + }, + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "test", + }, + }, + }, + }, + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": map[string]interface{}{ + "name": "test", + }, + }, + }, + }, + }, + want: []v1.ClusterExtensionRevisionPhase{ + { + Name: string(applier.PhaseDeploy), + Objects: []v1.ClusterExtensionRevisionObject{ + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "test", + }, + }, + }, + }, + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": map[string]interface{}{ + "name": "test", + }, + }, + }, + }, + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Service", + "metadata": map[string]interface{}{ + "name": "test", + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "sort by namespace within same GVK and phase", + objs: []v1.ClusterExtensionRevisionObject{ + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "test", + "namespace": "zebra", + }, + }, + }, + }, + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "test", + "namespace": "alpha", + }, + }, + }, + }, + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "test", + "namespace": "beta", + }, + }, + }, + }, + }, + want: []v1.ClusterExtensionRevisionPhase{ + { + Name: string(applier.PhaseDeploy), + Objects: []v1.ClusterExtensionRevisionObject{ + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "test", + "namespace": "alpha", + }, + }, + }, + }, + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "test", + "namespace": "beta", + }, + }, + }, + }, + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "test", + "namespace": "zebra", + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "sort by name within same GVK, namespace, and phase", + objs: []v1.ClusterExtensionRevisionObject{ + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "zoo", + "namespace": "default", + }, + }, + }, + }, + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "apple", + "namespace": "default", + }, + }, + }, + }, + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "banana", + "namespace": "default", + }, + }, + }, + }, + }, + want: []v1.ClusterExtensionRevisionPhase{ + { + Name: string(applier.PhaseDeploy), + Objects: []v1.ClusterExtensionRevisionObject{ + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "apple", + "namespace": "default", + }, + }, + }, + }, + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "banana", + "namespace": "default", + }, + }, + }, + }, + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "zoo", + "namespace": "default", + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "comprehensive sorting - all dimensions", + objs: []v1.ClusterExtensionRevisionObject{ + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]interface{}{ + "name": "app-z", + "namespace": "prod", + }, + }, + }, + }, + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": map[string]interface{}{ + "name": "secret-b", + "namespace": "prod", + }, + }, + }, + }, + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": map[string]interface{}{ + "name": "secret-a", + "namespace": "prod", + }, + }, + }, + }, + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "config", + "namespace": "dev", + }, + }, + }, + }, + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]interface{}{ + "name": "app-a", + "namespace": "prod", + }, + }, + }, + }, + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "config", + "namespace": "prod", + }, + }, + }, + }, + }, + want: []v1.ClusterExtensionRevisionPhase{ + { + Name: string(applier.PhaseDeploy), + Objects: []v1.ClusterExtensionRevisionObject{ + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "config", + "namespace": "dev", + }, + }, + }, + }, + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "config", + "namespace": "prod", + }, + }, + }, + }, + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": map[string]interface{}{ + "name": "secret-a", + "namespace": "prod", + }, + }, + }, + }, + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": map[string]interface{}{ + "name": "secret-b", + "namespace": "prod", + }, + }, + }, + }, + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]interface{}{ + "name": "app-a", + "namespace": "prod", + }, + }, + }, + }, + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]interface{}{ + "name": "app-z", + "namespace": "prod", + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "cluster-scoped vs namespaced resources - empty namespace sorts first", + objs: []v1.ClusterExtensionRevisionObject{ + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "rbac.authorization.k8s.io/v1", + "kind": "ClusterRole", + "metadata": map[string]interface{}{ + "name": "admin", + }, + }, + }, + }, + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "rbac.authorization.k8s.io/v1", + "kind": "ClusterRole", + "metadata": map[string]interface{}{ + "name": "viewer", + }, + }, + }, + }, + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "rbac.authorization.k8s.io/v1", + "kind": "Role", + "metadata": map[string]interface{}{ + "name": "admin", + "namespace": "default", + }, + }, + }, + }, + }, + want: []v1.ClusterExtensionRevisionPhase{ + { + Name: string(applier.PhaseRBAC), + Objects: []v1.ClusterExtensionRevisionObject{ + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "rbac.authorization.k8s.io/v1", + "kind": "ClusterRole", + "metadata": map[string]interface{}{ + "name": "admin", + }, + }, + }, + }, + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "rbac.authorization.k8s.io/v1", + "kind": "ClusterRole", + "metadata": map[string]interface{}{ + "name": "viewer", + }, + }, + }, + }, + { + Object: unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "rbac.authorization.k8s.io/v1", + "kind": "Role", + "metadata": map[string]interface{}{ + "name": "admin", + "namespace": "default", + }, + }, + }, + }, + }, + }, + }, }, } { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/operator-controller/applier/provider.go b/internal/operator-controller/applier/provider.go index ffb5eb559..cf75b28c8 100644 --- a/internal/operator-controller/applier/provider.go +++ b/internal/operator-controller/applier/provider.go @@ -13,7 +13,7 @@ import ( "github.com/operator-framework/api/pkg/operators/v1alpha1" ocv1 "github.com/operator-framework/operator-controller/api/v1" - "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/bundle" + "github.com/operator-framework/operator-controller/internal/operator-controller/config" "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/bundle/source" "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/render" ) @@ -69,19 +69,19 @@ func (r *RegistryV1ManifestProvider) Get(bundleFS fs.FS, ext *ocv1.ClusterExtens } if r.IsSingleOwnNamespaceEnabled { - bundleConfigBytes := extensionConfigBytes(ext) - // treat no config as empty to properly validate the configuration - // e.g. ensure that validation catches missing required fields - if bundleConfigBytes == nil { - bundleConfigBytes = []byte(`{}`) + schema, err := rv1.GetConfigSchema() + if err != nil { + return nil, fmt.Errorf("error getting configuration schema: %w", err) } - bundleConfig, err := bundle.UnmarshalConfig(bundleConfigBytes, rv1, ext.Spec.Namespace) + + bundleConfigBytes := extensionConfigBytes(ext) + bundleConfig, err := config.UnmarshalConfig(bundleConfigBytes, schema, ext.Spec.Namespace) if err != nil { - return nil, fmt.Errorf("invalid bundle configuration: %w", err) + return nil, fmt.Errorf("invalid ClusterExtension configuration: %w", err) } - if bundleConfig != nil && bundleConfig.WatchNamespace != nil { - opts = append(opts, render.WithTargetNamespaces(*bundleConfig.WatchNamespace)) + if watchNS := bundleConfig.GetWatchNamespace(); watchNS != nil { + opts = append(opts, render.WithTargetNamespaces(*watchNS)) } } diff --git a/internal/operator-controller/applier/provider_test.go b/internal/operator-controller/applier/provider_test.go index 4ec20bead..4138284a0 100644 --- a/internal/operator-controller/applier/provider_test.go +++ b/internal/operator-controller/applier/provider_test.go @@ -97,7 +97,7 @@ func Test_RegistryV1ManifestProvider_Integration(t *testing.T) { _, err := provider.Get(bundleFS, ext) require.Error(t, err) - require.Contains(t, err.Error(), "invalid bundle configuration") + require.Contains(t, err.Error(), "invalid ClusterExtension configuration") }) t.Run("returns rendered manifests", func(t *testing.T) { @@ -326,7 +326,7 @@ func Test_RegistryV1ManifestProvider_SingleOwnNamespaceSupport(t *testing.T) { }, }) require.Error(t, err) - require.Contains(t, err.Error(), "required field \"watchNamespace\" is missing") + require.Contains(t, err.Error(), `required field "watchNamespace" is missing`) }) t.Run("accepts bundles with {OwnNamespace} install modes when the appropriate configuration is given", func(t *testing.T) { @@ -371,7 +371,7 @@ func Test_RegistryV1ManifestProvider_SingleOwnNamespaceSupport(t *testing.T) { }, }) require.Error(t, err) - require.Contains(t, err.Error(), "required field \"watchNamespace\" is missing") + require.Contains(t, err.Error(), `required field "watchNamespace" is missing`) }) t.Run("rejects bundles with {OwnNamespace} install modes when watchNamespace is not install namespace", func(t *testing.T) { @@ -392,7 +392,9 @@ func Test_RegistryV1ManifestProvider_SingleOwnNamespaceSupport(t *testing.T) { }, }) require.Error(t, err) - require.Contains(t, err.Error(), "invalid 'watchNamespace' \"not-install-namespace\": must be install namespace (install-namespace)") + require.Contains(t, err.Error(), "invalid ClusterExtension configuration:") + require.Contains(t, err.Error(), "watchNamespace must be") + require.Contains(t, err.Error(), "install-namespace") }) t.Run("rejects bundles without AllNamespaces, SingleNamespace, or OwnNamespace install mode support when Single/OwnNamespace install mode support is enabled", func(t *testing.T) { diff --git a/internal/operator-controller/config/config.go b/internal/operator-controller/config/config.go new file mode 100644 index 000000000..8fcadf40a --- /dev/null +++ b/internal/operator-controller/config/config.go @@ -0,0 +1,371 @@ +// Package config validates configuration for different package format types. +// +// How it works: +// +// Each package format type (like registry+v1 or Helm) knows what configuration it accepts. +// When a user provides configuration, we validate it before creating a Config object. +// Once created, a Config is guaranteed to be valid - you never need to check it again. +// +// The validation uses JSON Schema: +// 1. Bundle provides its schema (what config is valid) +// 2. We validate the user's config against that schema +// 3. If valid, we create a Config object +// 4. If invalid, we return a helpful error message +// +// Design choices: +// +// - Validation happens once, when creating the Config. There's no Validate() method +// because once you have a Config, it's already been validated. +// +// - Config doesn't hold onto the schema. We only need the schema during validation, +// not after the Config is created. +// +// - You can't create a Config directly. You must go through UnmarshalConfig so that +// validation always happens. +package config + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/santhosh-tekuri/jsonschema/v6" + "sigs.k8s.io/yaml" +) + +const ( + // configSchemaID is a name we use to identify the config schema when compiling it. + // Think of it like a file name - it just needs to be consistent. + configSchemaID = "config-schema.json" + + // FormatOwnNamespaceInstallMode defines the format check to ensure that + // the watchNamespace must equal install namespace + FormatOwnNamespaceInstallMode = "ownNamespaceInstallMode" + // FormatSingleNamespaceInstallMode defines the format check to ensure that + // the watchNamespace must differ from install namespace + FormatSingleNamespaceInstallMode = "singleNamespaceInstallMode" +) + +// SchemaProvider lets each package format type describe what configuration it accepts. +// +// Different package format types provide schemas in different ways: +// - registry+v1: builds schema from the operator's install modes +// - Helm: reads schema from values.schema.json in the chart +// - registry+v2: (future) will have its own way +type SchemaProvider interface { + // GetConfigSchema returns a JSON Schema describing what configuration is valid. + // Returns nil if this package format type doesn't need configuration validation. + GetConfigSchema() (map[string]any, error) +} + +// Config holds validated configuration data from a ClusterExtension. +// +// Different package format types have different configuration options, so we store +// the data in a flexible format and provide accessor methods to get values out. +// +// Why there's no Validate() method: +// We validate configuration when creating a Config. If you have a Config object, +// it's already been validated - you don't need to check it again. You can't create +// a Config directly; you have to use UnmarshalConfig, which does the validation. +type Config map[string]any + +// newConfig creates a Config from already-validated data. +// This is unexported so all Configs must be created through UnmarshalConfig, +// which ensures validation always happens. +func newConfig(data map[string]any) *Config { + cfg := Config(data) + return &cfg +} + +// GetWatchNamespace returns the watchNamespace value if present in the configuration. +// Returns nil if watchNamespace is not set or is explicitly set to null. +func (c *Config) GetWatchNamespace() *string { + if c == nil || *c == nil { + return nil + } + val, exists := (*c)["watchNamespace"] + if !exists { + return nil + } + // User set watchNamespace: null - treat as "not configured" + if val == nil { + return nil + } + // Convert value to string. Schema validation ensures this is a string, + // but fmt.Sprintf handles edge cases defensively. + str := fmt.Sprintf("%v", val) + return &str +} + +// UnmarshalConfig takes user configuration, validates it, and creates a Config object. +// This is the only way to create a Config. +// +// What it does: +// 1. Checks the user's configuration against the schema (if provided) +// 2. If valid, creates a Config object +// 3. If invalid, returns an error explaining what's wrong +// +// Parameters: +// - bytes: the user's configuration in YAML or JSON. If nil, we treat it as empty ({}) +// - schema: describes what configuration is valid. If nil, we skip validation +// - installNamespace: the namespace where the operator will be installed. We use this +// to validate namespace constraints (e.g., OwnNamespace mode requires watchNamespace +// to equal installNamespace) +// +// If the user doesn't provide any configuration but the package format type requires some fields +// (like watchNamespace), validation will fail with a helpful error. +func UnmarshalConfig(bytes []byte, schema map[string]any, installNamespace string) (*Config, error) { + // nil config becomes {} so we can validate required fields + if bytes == nil { + bytes = []byte("{}") + } + + // Step 1: Validate against the schema if provided + if schema != nil { + if err := validateConfigWithSchema(bytes, schema, installNamespace); err != nil { + return nil, fmt.Errorf("invalid configuration: %w", err) + } + } + + // Step 2: Parse into Config struct + // We use yaml.Unmarshal to parse the validated config into an opaque map. + // Schema validation has already ensured correctness. + var configData map[string]any + if err := yaml.Unmarshal(bytes, &configData); err != nil { + return nil, fmt.Errorf("error unmarshalling configuration: %w", formatUnmarshalError(err)) + } + + return newConfig(configData), nil +} + +// validateConfigWithSchema checks if the user's config matches the schema. +// +// We create a fresh validator each time because the namespace constraints depend on +// which namespace this specific ClusterExtension is being installed into. Each +// ClusterExtension might have a different installNamespace, so we can't reuse validators. +func validateConfigWithSchema(configBytes []byte, schema map[string]any, installNamespace string) error { + var configData interface{} + if err := yaml.Unmarshal(configBytes, &configData); err != nil { + return formatUnmarshalError(err) + } + + compiler := jsonschema.NewCompiler() + + compiler.RegisterFormat(&jsonschema.Format{ + Name: FormatOwnNamespaceInstallMode, + Validate: func(value interface{}) error { + // Check it equals install namespace (if installNamespace is set) + // If installNamespace is empty, we can't validate the constraint properly, + // so we skip validation and accept any value. This is a fallback for edge + // cases where the install namespace isn't known yet. + if installNamespace == "" { + return nil + } + str, ok := value.(string) + if !ok { + return fmt.Errorf("value must be a string") + } + if str != installNamespace { + return fmt.Errorf("invalid value %q: watchNamespace must be %q (the namespace where the operator is installed) because this operator only supports OwnNamespace install mode", str, installNamespace) + } + return nil + }, + }) + compiler.RegisterFormat(&jsonschema.Format{ + Name: FormatSingleNamespaceInstallMode, + Validate: func(value interface{}) error { + // Check it does NOT equal install namespace (if installNamespace is set) + // If installNamespace is empty, we can't validate the constraint properly, + // so we skip validation and accept any value. This is a fallback for edge + // cases where the install namespace isn't known yet. + if installNamespace == "" { + return nil + } + str, ok := value.(string) + if !ok { + return fmt.Errorf("value must be a string") + } + if str == installNamespace { + return fmt.Errorf("invalid value %q: watchNamespace must be different from %q (the install namespace) because this operator uses SingleNamespace install mode to watch a different namespace", str, installNamespace) + } + return nil + }, + }) + + if err := compiler.AddResource(configSchemaID, schema); err != nil { + return fmt.Errorf("failed to load schema: %w", err) + } + + compiledSchema, err := compiler.Compile(configSchemaID) + if err != nil { + return fmt.Errorf("failed to compile schema: %w", err) + } + + if err := compiledSchema.Validate(configData); err != nil { + return formatSchemaError(err) + } + + return nil +} + +// formatSchemaError converts JSON schema validation errors into user-friendly messages. +// If multiple validation errors exist, it combines them into a single error message. +func formatSchemaError(err error) error { + ve := &jsonschema.ValidationError{} + ok := errors.As(err, &ve) + if !ok { + // Not a ValidationError, return as-is + // Caller (UnmarshalConfig) will add "invalid configuration:" prefix + return err + } + + // Use BasicOutput() to get structured error information + // This is more robust than parsing error strings + output := ve.BasicOutput() + if output == nil || len(output.Errors) == 0 { + // No structured errors available, fallback to error message + // Note: Using errors.New since ve.Error() is already a formatted string + return errors.New(ve.Error()) + } + + // Collect all error messages + var errorMessages []string + for _, errUnit := range output.Errors { + msg := formatSingleError(errUnit) + if msg != "" { + errorMessages = append(errorMessages, msg) + } + } + + if len(errorMessages) == 0 { + return fmt.Errorf("invalid configuration: %w", ve) + } + + // Single error - return it directly + if len(errorMessages) == 1 { + return errors.New(errorMessages[0]) + } + + // Multiple errors - combine them + return fmt.Errorf("multiple errors found:\n - %s", strings.Join(errorMessages, "\n - ")) +} + +// formatSingleError formats a single validation error from the schema library. +func formatSingleError(errUnit jsonschema.OutputUnit) string { + // Check the keyword location to identify the error type + switch { + case strings.Contains(errUnit.KeywordLocation, "/required"): + // Missing required field + fieldName := extractFieldNameFromMessage(errUnit.Error) + if fieldName != "" { + return fmt.Sprintf("required field %q is missing", fieldName) + } + return "required field is missing" + + case strings.Contains(errUnit.KeywordLocation, "/additionalProperties"): + // Unknown/additional field + fieldName := extractFieldNameFromMessage(errUnit.Error) + if fieldName != "" { + return fmt.Sprintf("unknown field %q", fieldName) + } + return "unknown field" + + case strings.Contains(errUnit.KeywordLocation, "/type"): + // Type mismatch (e.g., got null, want string) + fieldPath := buildFieldPath(errUnit.InstanceLocation) + if fieldPath != "" { + // Check if this is a "null instead of required value" case + if errUnit.Error != nil && strings.Contains(errUnit.Error.String(), "got null") { + return fmt.Sprintf("required field %q is missing", fieldPath) + } + return fmt.Sprintf("invalid type for field %q: %s", fieldPath, errUnit.Error.String()) + } + return fmt.Sprintf("invalid type: %s", errUnit.Error.String()) + + case strings.Contains(errUnit.KeywordLocation, "/format"): + // Custom format validation (e.g., OwnNamespace, SingleNamespace constraints) + // These already have good error messages from our custom validators + if errUnit.Error != nil { + return errUnit.Error.String() + } + fieldPath := buildFieldPath(errUnit.InstanceLocation) + return fmt.Sprintf("invalid format for field %q", fieldPath) + + case strings.Contains(errUnit.KeywordLocation, "/anyOf"): + // anyOf validation failed - could be null or wrong type + // This happens when a field accepts [null, string] but got something else + fieldPath := buildFieldPath(errUnit.InstanceLocation) + if fieldPath != "" { + return fmt.Sprintf("invalid value for field %q", fieldPath) + } + return "invalid value" + + default: + // Unknown error type - return the library's error message + // This serves as a fallback for future schema features we haven't customized yet + if errUnit.Error != nil { + return errUnit.Error.String() + } + return "" + } +} + +// extractFieldNameFromMessage extracts the field name from error messages. +// Example: "missing property 'watchNamespace'" -> "watchNamespace" +// Example: "additional properties 'unknownField' not allowed" -> "unknownField" +func extractFieldNameFromMessage(errOutput *jsonschema.OutputError) string { + if errOutput == nil { + return "" + } + msg := errOutput.String() + + // Look for field names in single quotes (library's format) + if idx := strings.Index(msg, "'"); idx != -1 { + remaining := msg[idx+1:] + if endIdx := strings.Index(remaining, "'"); endIdx != -1 { + return remaining[:endIdx] + } + } + + return "" +} + +// buildFieldPath constructs a field path from instance location array. +// Example: ["watchNamespace"] -> "watchNamespace" +// Example: ["spec", "namespace"] -> "spec.namespace" +func buildFieldPath(location string) string { + // Instance location comes as a JSON pointer like "/watchNamespace" + if location == "" || location == "/" { + return "" + } + // Remove leading slash + path := strings.TrimPrefix(location, "/") + // Replace JSON pointer slashes with dots for readability + path = strings.ReplaceAll(path, "/", ".") + return path +} + +// formatUnmarshalError makes YAML/JSON parsing errors easier to understand. +func formatUnmarshalError(err error) error { + var typeErr *json.UnmarshalTypeError + if errors.As(err, &typeErr) { + if typeErr.Field == "" { + return errors.New("input is not a valid JSON object") + } + return fmt.Errorf("invalid value type for field %q: expected %q but got %q", + typeErr.Field, typeErr.Type.String(), typeErr.Value) + } + + // Unwrap to core error and strip "json:" or "yaml:" prefix + current := err + for { + unwrapped := errors.Unwrap(current) + if unwrapped == nil { + parts := strings.Split(current.Error(), ":") + coreMessage := strings.TrimSpace(parts[len(parts)-1]) + return errors.New(coreMessage) + } + current = unwrapped + } +} diff --git a/internal/operator-controller/config/config_test.go b/internal/operator-controller/config/config_test.go new file mode 100644 index 000000000..95bb98f0b --- /dev/null +++ b/internal/operator-controller/config/config_test.go @@ -0,0 +1,574 @@ +package config_test + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + "k8s.io/utils/ptr" + + "github.com/operator-framework/api/pkg/operators/v1alpha1" + + "github.com/operator-framework/operator-controller/internal/operator-controller/config" + "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/bundle" + "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/util/testing/clusterserviceversion" +) + +func Test_UnmarshalConfig(t *testing.T) { + for _, tc := range []struct { + name string + rawConfig []byte + supportedInstallModes []v1alpha1.InstallModeType + installNamespace string + expectedErrMessage string + expectedWatchNamespace *string // Expected value from GetWatchNamespace() + }{ + { + name: "accepts nil config when AllNamespaces is supported", + supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeAllNamespaces}, + rawConfig: nil, + expectedWatchNamespace: nil, + }, + { + name: "rejects nil config when OwnNamespace-only requires watchNamespace", + supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeOwnNamespace}, + rawConfig: nil, + expectedErrMessage: `required field "watchNamespace" is missing`, + }, + { + name: "rejects nil config when SingleNamespace-only requires watchNamespace", + supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeSingleNamespace}, + rawConfig: nil, + expectedErrMessage: `required field "watchNamespace" is missing`, + }, + { + name: "accepts json config", + supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeSingleNamespace}, + rawConfig: []byte(`{"watchNamespace": "some-namespace"}`), + installNamespace: "install-ns", // SingleNamespace requires watchNamespace != installNamespace + expectedWatchNamespace: ptr.To("some-namespace"), + }, + { + name: "accepts yaml config", + supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeSingleNamespace}, + rawConfig: []byte(`watchNamespace: some-namespace`), + installNamespace: "install-ns", // SingleNamespace requires watchNamespace != installNamespace + expectedWatchNamespace: ptr.To("some-namespace"), + }, + { + name: "rejects invalid json", + supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeSingleNamespace}, + rawConfig: []byte(`{"hello`), + expectedErrMessage: `invalid configuration: found unexpected end of stream`, + }, + { + name: "rejects valid json that isn't of object type", + supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeSingleNamespace}, + rawConfig: []byte(`true`), + expectedErrMessage: `got boolean, want object`, + }, + { + name: "rejects additional fields", + supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeAllNamespaces}, + rawConfig: []byte(`somekey: somevalue`), + expectedErrMessage: `unknown field "somekey"`, + }, + { + name: "rejects valid json but invalid registry+v1", + supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeSingleNamespace}, + rawConfig: []byte(`{"watchNamespace": {"hello": "there"}}`), + expectedErrMessage: `got object, want string`, + }, + { + name: "rejects with unknown field when install modes {AllNamespaces}", + supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeAllNamespaces}, + rawConfig: []byte(`{"watchNamespace": "some-namespace"}`), + expectedErrMessage: `unknown field "watchNamespace"`, + }, + { + name: "rejects with unknown field when install modes {MultiNamespace}", + supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeMultiNamespace}, + rawConfig: []byte(`{"watchNamespace": "some-namespace"}`), + expectedErrMessage: `unknown field "watchNamespace"`, + }, + { + name: "reject with unknown field when install modes {AllNamespaces, MultiNamespace}", + supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeAllNamespaces, v1alpha1.InstallModeTypeMultiNamespace}, + rawConfig: []byte(`{"watchNamespace": "some-namespace"}`), + expectedErrMessage: `unknown field "watchNamespace"`, + }, + { + name: "reject with required field when install modes {OwnNamespace} and watchNamespace is null", + supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeOwnNamespace}, + rawConfig: []byte(`{"watchNamespace": null}`), + expectedErrMessage: `required field "watchNamespace" is missing`, + }, + { + name: "reject with required field when install modes {OwnNamespace} and watchNamespace is missing", + supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeOwnNamespace}, + rawConfig: []byte(`{}`), + expectedErrMessage: `required field "watchNamespace" is missing`, + }, + { + name: "reject with required field when install modes {MultiNamespace, OwnNamespace} and watchNamespace is null", + supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeMultiNamespace, v1alpha1.InstallModeTypeOwnNamespace}, + rawConfig: []byte(`{"watchNamespace": null}`), + expectedErrMessage: `required field "watchNamespace" is missing`, + }, + { + name: "reject with required field when install modes {MultiNamespace, OwnNamespace} and watchNamespace is missing", + supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeMultiNamespace, v1alpha1.InstallModeTypeOwnNamespace}, + rawConfig: []byte(`{}`), + expectedErrMessage: `required field "watchNamespace" is missing`, + }, + { + name: "accepts when install modes {SingleNamespace} and watchNamespace != install namespace", + supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeSingleNamespace}, + rawConfig: []byte(`{"watchNamespace": "some-namespace"}`), + installNamespace: "install-ns", + expectedWatchNamespace: ptr.To("some-namespace"), + }, + { + name: "accepts when install modes {AllNamespaces, SingleNamespace} and watchNamespace != install namespace", + supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeAllNamespaces, v1alpha1.InstallModeTypeSingleNamespace}, + rawConfig: []byte(`{"watchNamespace": "some-namespace"}`), + installNamespace: "install-ns", + expectedWatchNamespace: ptr.To("some-namespace"), + }, + { + name: "accepts when install modes {MultiNamespace, SingleNamespace} and watchNamespace != install namespace", + supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeMultiNamespace, v1alpha1.InstallModeTypeSingleNamespace}, + rawConfig: []byte(`{"watchNamespace": "some-namespace"}`), + installNamespace: "install-ns", + expectedWatchNamespace: ptr.To("some-namespace"), + }, + { + name: "accepts when install modes {OwnNamespace, SingleNamespace} and watchNamespace != install namespace", + supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeOwnNamespace, v1alpha1.InstallModeTypeSingleNamespace}, + rawConfig: []byte(`{"watchNamespace": "some-namespace"}`), + installNamespace: "not-namespace", + expectedWatchNamespace: ptr.To("some-namespace"), + }, + { + name: "rejects when install modes {SingleNamespace} and watchNamespace == install namespace", + supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeSingleNamespace}, + rawConfig: []byte(`{"watchNamespace": "some-namespace"}`), + installNamespace: "some-namespace", + expectedErrMessage: "invalid configuration:", + }, + { + name: "rejects when install modes {AllNamespaces, SingleNamespace} and watchNamespace == install namespace", + supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeAllNamespaces, v1alpha1.InstallModeTypeSingleNamespace}, + rawConfig: []byte(`{"watchNamespace": "some-namespace"}`), + installNamespace: "some-namespace", + expectedErrMessage: "invalid configuration:", + }, + { + name: "rejects when install modes {MultiNamespace, SingleNamespace} and watchNamespace == install namespace", + supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeMultiNamespace, v1alpha1.InstallModeTypeSingleNamespace}, + rawConfig: []byte(`{"watchNamespace": "some-namespace"}`), + installNamespace: "some-namespace", + expectedErrMessage: "invalid configuration:", + }, + { + name: "accepts when install modes {AllNamespaces, OwnNamespace} and watchNamespace == install namespace", + supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeAllNamespaces, v1alpha1.InstallModeTypeOwnNamespace}, + rawConfig: []byte(`{"watchNamespace": "some-namespace"}`), + installNamespace: "some-namespace", + expectedWatchNamespace: ptr.To("some-namespace"), + }, + { + name: "accepts when install modes {OwnNamespace, SingleNamespace} and watchNamespace == install namespace", + supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeOwnNamespace, v1alpha1.InstallModeTypeSingleNamespace}, + rawConfig: []byte(`{"watchNamespace": "some-namespace"}`), + installNamespace: "some-namespace", + expectedWatchNamespace: ptr.To("some-namespace"), + }, + { + name: "rejects when install modes {AllNamespaces, OwnNamespace} and watchNamespace != install namespace", + supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeAllNamespaces, v1alpha1.InstallModeTypeOwnNamespace}, + rawConfig: []byte(`{"watchNamespace": "some-namespace"}`), + installNamespace: "not-some-namespace", + expectedErrMessage: "invalid configuration:", + }, + { + name: "rejects with required field error when install modes {SingleNamespace} and watchNamespace is nil", + supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeSingleNamespace}, + rawConfig: []byte(`{"watchNamespace": null}`), + installNamespace: "not-some-namespace", + expectedErrMessage: `required field "watchNamespace" is missing`, + }, + { + name: "rejects with required field error when install modes {SingleNamespace, OwnNamespace} and watchNamespace is nil", + supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeSingleNamespace, v1alpha1.InstallModeTypeOwnNamespace}, + rawConfig: []byte(`{"watchNamespace": null}`), + installNamespace: "not-some-namespace", + expectedErrMessage: `required field "watchNamespace" is missing`, + }, + { + name: "rejects with required field error when install modes {SingleNamespace, MultiNamespace} and watchNamespace is nil", + supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeSingleNamespace, v1alpha1.InstallModeTypeMultiNamespace}, + rawConfig: []byte(`{"watchNamespace": null}`), + installNamespace: "not-some-namespace", + expectedErrMessage: `required field "watchNamespace" is missing`, + }, + { + name: "rejects with required field error when install modes {SingleNamespace} and watchNamespace is missing", + supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeSingleNamespace}, + rawConfig: []byte(`{}`), + installNamespace: "not-some-namespace", + expectedErrMessage: `required field "watchNamespace" is missing`, + }, + { + name: "rejects with required field error when install modes {SingleNamespace, OwnNamespace} and watchNamespace is missing", + supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeSingleNamespace, v1alpha1.InstallModeTypeOwnNamespace}, + rawConfig: []byte(`{}`), + installNamespace: "not-some-namespace", + expectedErrMessage: `required field "watchNamespace" is missing`, + }, + { + name: "rejects with required field error when install modes {SingleNamespace, MultiNamespace} and watchNamespace is missing", + supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeSingleNamespace, v1alpha1.InstallModeTypeMultiNamespace}, + rawConfig: []byte(`{}`), + installNamespace: "not-some-namespace", + expectedErrMessage: `required field "watchNamespace" is missing`, + }, + { + name: "rejects with required field error when install modes {SingleNamespace, OwnNamespace, MultiNamespace} and watchNamespace is nil", + supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeSingleNamespace, v1alpha1.InstallModeTypeOwnNamespace, v1alpha1.InstallModeTypeMultiNamespace}, + rawConfig: []byte(`{"watchNamespace": null}`), + installNamespace: "not-some-namespace", + expectedErrMessage: `required field "watchNamespace" is missing`, + }, + { + name: "accepts null watchNamespace when install modes {AllNamespaces, OwnNamespace} and watchNamespace is nil", + supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeAllNamespaces, v1alpha1.InstallModeTypeOwnNamespace}, + rawConfig: []byte(`{"watchNamespace": null}`), + installNamespace: "not-some-namespace", + expectedWatchNamespace: nil, + }, + { + name: "accepts null watchNamespace when install modes {AllNamespaces, OwnNamespace, MultiNamespace} and watchNamespace is nil", + supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeAllNamespaces, v1alpha1.InstallModeTypeOwnNamespace, v1alpha1.InstallModeTypeMultiNamespace}, + rawConfig: []byte(`{"watchNamespace": null}`), + installNamespace: "not-some-namespace", + expectedWatchNamespace: nil, + }, + { + name: "accepts no watchNamespace when install modes {AllNamespaces, OwnNamespace} and watchNamespace is nil", + supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeAllNamespaces, v1alpha1.InstallModeTypeOwnNamespace}, + rawConfig: []byte(`{}`), + installNamespace: "not-some-namespace", + expectedWatchNamespace: nil, + }, + { + name: "accepts no watchNamespace when install modes {AllNamespaces, OwnNamespace, MultiNamespace} and watchNamespace is nil", + supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeAllNamespaces, v1alpha1.InstallModeTypeOwnNamespace, v1alpha1.InstallModeTypeMultiNamespace}, + rawConfig: []byte(`{}`), + installNamespace: "not-some-namespace", + expectedWatchNamespace: nil, + }, + { + name: "skips validation when installNamespace is empty for OwnNamespace only", + supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeOwnNamespace}, + rawConfig: []byte(`{"watchNamespace": "valid-ns"}`), + installNamespace: "", + expectedWatchNamespace: ptr.To("valid-ns"), + }, + } { + t.Run(tc.name, func(t *testing.T) { + var rv1 bundle.RegistryV1 + if tc.supportedInstallModes != nil { + rv1 = bundle.RegistryV1{ + CSV: clusterserviceversion.Builder(). + WithName("test-operator"). + WithInstallModeSupportFor(tc.supportedInstallModes...). + Build(), + } + } + + schema, err := rv1.GetConfigSchema() + require.NoError(t, err) + + cfg, err := config.UnmarshalConfig(tc.rawConfig, schema, tc.installNamespace) + if tc.expectedErrMessage != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tc.expectedErrMessage) + } else { + require.NoError(t, err) + require.NotNil(t, cfg) + if tc.expectedWatchNamespace == nil { + require.Nil(t, cfg.GetWatchNamespace()) + } else { + require.Equal(t, *tc.expectedWatchNamespace, *cfg.GetWatchNamespace()) + } + } + }) + } +} + +// Test_UnmarshalConfig_EmptySchema tests when a ClusterExtension doesn't provide a configuration schema. +func Test_UnmarshalConfig_EmptySchema(t *testing.T) { + for _, tc := range []struct { + name string + rawConfig []byte + expectedErrMessage string + expectedWatchNamespace *string + }{ + { + name: "no config provided", + rawConfig: nil, + expectedWatchNamespace: nil, + }, + { + name: "empty config provided", + rawConfig: []byte(`{}`), + expectedWatchNamespace: nil, + }, + { + name: "config with watchNamespace provided", + rawConfig: []byte(`{"watchNamespace": "some-ns"}`), + expectedWatchNamespace: ptr.To("some-ns"), + }, + { + name: "config with unknown fields provided", + rawConfig: []byte(`{"someField": "someValue"}`), + expectedWatchNamespace: nil, + }, + } { + t.Run(tc.name, func(t *testing.T) { + emptySchemaBundle := &mockEmptySchemaBundle{} + schema, err := emptySchemaBundle.GetConfigSchema() + require.NoError(t, err) + + config, err := config.UnmarshalConfig(tc.rawConfig, schema, "my-namespace") + + if tc.expectedErrMessage != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tc.expectedErrMessage) + } else { + require.NoError(t, err) + require.NotNil(t, config) + if tc.expectedWatchNamespace == nil { + require.Nil(t, config.GetWatchNamespace()) + } else { + require.Equal(t, *tc.expectedWatchNamespace, *config.GetWatchNamespace()) + } + } + }) + } +} + +// Test_UnmarshalConfig_HelmLike proves validation works the same for ANY package format type. +// +// - registry+v1 -> generates schema from install modes +// - Helm -> reads values.schema.json from chart +// - registry+v2 -> (future) provides schema via its own mechanism +// +// Same validation process regardless of package format type. +func Test_UnmarshalConfig_HelmLike(t *testing.T) { + for _, tc := range []struct { + name string + rawConfig []byte + helmSchema string // what values.schema.json would contain + expectedErrMessage string + expectedWatchNamespace *string + }{ + { + name: "Helm chart with typical config values (no watchNamespace)", + rawConfig: []byte(`{ + "replicaCount": 3, + "image": {"tag": "v1.2.3"}, + "service": {"port": 8080} + }`), + helmSchema: `{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "replicaCount": {"type": "integer", "minimum": 1}, + "image": { + "type": "object", + "properties": { + "tag": {"type": "string"} + } + }, + "service": { + "type": "object", + "properties": { + "port": {"type": "integer"} + } + } + } + }`, + expectedWatchNamespace: nil, + }, + { + name: "Helm chart that ALSO uses watchNamespace (mixed config)", + rawConfig: []byte(`{ + "watchNamespace": "my-app-namespace", + "replicaCount": 2, + "debug": true + }`), + helmSchema: `{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "watchNamespace": {"type": "string"}, + "replicaCount": {"type": "integer"}, + "debug": {"type": "boolean"} + } + }`, + // watchNamespace gets extracted, other fields validated by schema + expectedWatchNamespace: ptr.To("my-app-namespace"), + }, + { + name: "Schema validation catches constraint violations (replicaCount below minimum)", + rawConfig: []byte(`{"replicaCount": 0}`), + helmSchema: `{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "replicaCount": {"type": "integer", "minimum": 1} + } + }`, + expectedErrMessage: "invalid configuration:", + }, + { + name: "Schema validation catches type mismatches", + rawConfig: []byte(`{"replicaCount": "three"}`), + helmSchema: `{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "replicaCount": {"type": "integer"} + } + }`, + expectedErrMessage: "invalid configuration:", + }, + { + name: "Empty config is valid when no required fields", + rawConfig: nil, + helmSchema: `{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "replicaCount": {"type": "integer", "default": 1} + } + }`, + expectedWatchNamespace: nil, + }, + { + name: "Required fields are enforced by schema", + rawConfig: []byte(`{"optional": "value"}`), + helmSchema: `{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["requiredField"], + "properties": { + "requiredField": {"type": "string"}, + "optional": {"type": "string"} + } + }`, + expectedErrMessage: `required field "requiredField" is missing`, + }, + { + name: "Helm with watchNamespace accepts any string value (K8s validates at runtime)", + rawConfig: []byte(`{ + "watchNamespace": "any-value-here", + "replicaCount": 2 + }`), + helmSchema: `{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "watchNamespace": {"type": "string"}, + "replicaCount": {"type": "integer"} + } + }`, + expectedWatchNamespace: ptr.To("any-value-here"), + }, + { + name: "Helm with watchNamespace using ownNamespaceInstallMode format (OwnNamespace-like)", + rawConfig: []byte(`{ + "watchNamespace": "wrong-namespace" + }`), + helmSchema: `{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["watchNamespace"], + "properties": { + "watchNamespace": {"type": "string", "format": "ownNamespaceInstallMode"} + } + }`, + expectedErrMessage: "invalid configuration:", + }, + { + name: "Helm with watchNamespace using singleNamespaceInstallMode format (SingleNamespace-like)", + rawConfig: []byte(`{ + "watchNamespace": "my-namespace" + }`), + helmSchema: `{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["watchNamespace"], + "properties": { + "watchNamespace": {"type": "string", "format": "singleNamespaceInstallMode"} + } + }`, + expectedErrMessage: "invalid configuration:", + }, + } { + t.Run(tc.name, func(t *testing.T) { + // Create a mock Helm package (real Helm would read values.schema.json) + helmBundle := &mockHelmBundle{schema: tc.helmSchema} + schema, err := helmBundle.GetConfigSchema() + require.NoError(t, err) + + // Same validation function works for Helm, registry+v1, registry+v2, etc. + config, err := config.UnmarshalConfig(tc.rawConfig, schema, "my-namespace") + + if tc.expectedErrMessage != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tc.expectedErrMessage) + } else { + require.NoError(t, err) + require.NotNil(t, config) + if tc.expectedWatchNamespace == nil { + require.Nil(t, config.GetWatchNamespace()) + } else { + require.Equal(t, *tc.expectedWatchNamespace, *config.GetWatchNamespace()) + } + } + }) + } +} + +// mockHelmBundle shows how Helm would plug into the validation system. +// +// Real implementation would: +// 1. Read values.schema.json from the Helm chart package +// 2. Parse it into a map[string]any +// 3. Return it (just like registry+v1 returns its generated schema) +// 4. Let the shared validation logic handle the rest +type mockHelmBundle struct { + schema string +} + +// GetConfigSchema returns the schema (in real Helm, read from values.schema.json). +func (h *mockHelmBundle) GetConfigSchema() (map[string]any, error) { + if h.schema == "" { + return nil, nil + } + var schemaMap map[string]any + if err := json.Unmarshal([]byte(h.schema), &schemaMap); err != nil { + return nil, err + } + return schemaMap, nil +} + +// mockEmptySchemaBundle represents a ClusterExtension that doesn't provide a configuration schema. +type mockEmptySchemaBundle struct{} + +func (e *mockEmptySchemaBundle) GetConfigSchema() (map[string]any, error) { + return nil, nil +} diff --git a/internal/operator-controller/config/error_formatting_test.go b/internal/operator-controller/config/error_formatting_test.go new file mode 100644 index 000000000..557e21019 --- /dev/null +++ b/internal/operator-controller/config/error_formatting_test.go @@ -0,0 +1,174 @@ +package config_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/operator-framework/api/pkg/operators/v1alpha1" + + "github.com/operator-framework/operator-controller/internal/operator-controller/config" + "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/bundle" + "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/util/testing/clusterserviceversion" +) + +// Test_ErrorFormatting_SchemaLibraryVersion verifies error messages from the JSON schema +// library and our custom format validators. +// +// These tests serve two purposes: +// 1. Guard against breaking changes if we upgrade github.com/santhosh-tekuri/jsonschema/v6 +// (tests for formatSchemaError parsing may need updates) +// 2. Document the actual error messages end users see (especially for namespace constraints) +func Test_ErrorFormatting_SchemaLibraryVersion(t *testing.T) { + for _, tc := range []struct { + name string + rawConfig []byte + supportedInstallModes []v1alpha1.InstallModeType + installNamespace string + // We verify the error message contains these key phrases + expectedErrSubstrings []string + }{ + { + name: "Unknown field error formatting", + rawConfig: []byte(`{"unknownField": "value"}`), + supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeAllNamespaces}, + expectedErrSubstrings: []string{ + "unknown field", + "unknownField", + }, + }, + { + name: "Required field missing error formatting", + rawConfig: []byte(`{}`), + supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeOwnNamespace}, + expectedErrSubstrings: []string{ + "required field", + "watchNamespace", + "is missing", + }, + }, + { + name: "Required field null error formatting", + rawConfig: []byte(`{"watchNamespace": null}`), + supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeSingleNamespace}, + expectedErrSubstrings: []string{ + "required field", + "watchNamespace", + "is missing", + }, + }, + { + name: "Type mismatch error formatting", + rawConfig: []byte(`{"watchNamespace": 123}`), + supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeSingleNamespace}, + expectedErrSubstrings: []string{ + "invalid type", + "watchNamespace", + }, + }, + { + name: "OwnNamespace constraint error formatting", + rawConfig: []byte(`{"watchNamespace": "wrong-namespace"}`), + supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeOwnNamespace}, + installNamespace: "correct-namespace", + expectedErrSubstrings: []string{ + "invalid value", + "wrong-namespace", + "watchNamespace must be", + "correct-namespace", + "the namespace where the operator is installed", + "OwnNamespace install mode", + }, + }, + { + name: "SingleNamespace constraint error formatting", + rawConfig: []byte(`{"watchNamespace": "install-ns"}`), + supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeSingleNamespace}, + installNamespace: "install-ns", + expectedErrSubstrings: []string{ + "invalid value", + "install-ns", + "watchNamespace must be different from", + "the install namespace", + "SingleNamespace install mode", + "watch a different namespace", + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + rv1 := bundle.RegistryV1{ + CSV: clusterserviceversion.Builder(). + WithName("test-operator"). + WithInstallModeSupportFor(tc.supportedInstallModes...). + Build(), + } + + schema, err := rv1.GetConfigSchema() + require.NoError(t, err) + + _, err = config.UnmarshalConfig(tc.rawConfig, schema, tc.installNamespace) + require.Error(t, err, "Expected validation error") + + errMsg := err.Error() + for _, substring := range tc.expectedErrSubstrings { + require.Contains(t, errMsg, substring, + "Error message should contain %q. Full error: %s", substring, errMsg) + } + }) + } +} + +// Test_ErrorFormatting_YAMLParseErrors verifies YAML/JSON parsing errors are formatted correctly. +func Test_ErrorFormatting_YAMLParseErrors(t *testing.T) { + for _, tc := range []struct { + name string + rawConfig []byte + expectedErrSubstrings []string + }{ + { + name: "Malformed JSON", + rawConfig: []byte(`{"incomplete`), + expectedErrSubstrings: []string{ + "unexpected end of stream", + }, + }, + { + name: "Non-object JSON", + rawConfig: []byte(`true`), + expectedErrSubstrings: []string{ + "invalid type", + "got boolean, want object", + }, + }, + { + name: "Wrong type for field", + rawConfig: []byte(`{"watchNamespace": {"nested": "object"}}`), + expectedErrSubstrings: []string{ + "invalid type", + "got object, want string", + "watchNamespace", + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + rv1 := bundle.RegistryV1{ + CSV: clusterserviceversion.Builder(). + WithName("test-operator"). + WithInstallModeSupportFor(v1alpha1.InstallModeTypeSingleNamespace). + Build(), + } + + schema, err := rv1.GetConfigSchema() + require.NoError(t, err) + + _, err = config.UnmarshalConfig(tc.rawConfig, schema, "test-namespace") + require.Error(t, err, "Expected parse error") + + errMsg := err.Error() + for _, substring := range tc.expectedErrSubstrings { + require.Contains(t, errMsg, substring, + "Error message should contain %q. Full error: %s", substring, errMsg) + } + }) + } +} diff --git a/internal/operator-controller/controllers/clusterextension_controller.go b/internal/operator-controller/controllers/clusterextension_controller.go index 7bcedde65..d8d9d8de0 100644 --- a/internal/operator-controller/controllers/clusterextension_controller.go +++ b/internal/operator-controller/controllers/clusterextension_controller.go @@ -280,7 +280,8 @@ func (r *ClusterExtensionReconciler) reconcile(ctx context.Context, ext *ocv1.Cl return ctrl.Result{}, err } - storeLbls := map[string]string{ + // The following values will be stored as annotations and not labels + revisionAnnotations := map[string]string{ labels.BundleNameKey: resolvedRevisionMetadata.Name, labels.PackageNameKey: resolvedRevisionMetadata.Package, labels.BundleVersionKey: resolvedRevisionMetadata.Version, @@ -297,7 +298,7 @@ func (r *ClusterExtensionReconciler) reconcile(ctx context.Context, ext *ocv1.Cl // to ensure exponential backoff can occur: // - Permission errors (it is not possible to watch changes to permissions. // The only way to eventually recover from permission errors is to keep retrying). - rolloutSucceeded, rolloutStatus, err := r.Applier.Apply(ctx, imageFS, ext, objLbls, storeLbls) + rolloutSucceeded, rolloutStatus, err := r.Applier.Apply(ctx, imageFS, ext, objLbls, revisionAnnotations) // Set installed status if rolloutSucceeded { @@ -531,7 +532,7 @@ func (d *BoxcutterRevisionStatesGetter) GetRevisionStates(ctx context.Context, e // recent revisions. We should consolidate to avoid code duplication. existingRevisionList := &ocv1.ClusterExtensionRevisionList{} if err := d.Reader.List(ctx, existingRevisionList, client.MatchingLabels{ - ClusterExtensionRevisionOwnerLabel: ext.Name, + labels.OwnerNameKey: ext.Name, }); err != nil { return nil, fmt.Errorf("listing revisions: %w", err) } @@ -549,11 +550,11 @@ func (d *BoxcutterRevisionStatesGetter) GetRevisionStates(ctx context.Context, e continue } - // TODO: the setting of these annotations (happens in boxcutter applier when we pass in "storageLabels") + // TODO: the setting of these annotations (happens in boxcutter applier when we pass in "revisionAnnotations") // is fairly decoupled from this code where we get the annotations back out. We may want to co-locate // the set/get logic a bit better to make it more maintainable and less likely to get out of sync. rm := &RevisionMetadata{ - Package: rev.Labels[labels.PackageNameKey], + Package: rev.Annotations[labels.PackageNameKey], Image: rev.Annotations[labels.BundleReferenceKey], BundleMetadata: ocv1.BundleMetadata{ Name: rev.Annotations[labels.BundleNameKey], diff --git a/internal/operator-controller/controllers/clusterextension_controller_test.go b/internal/operator-controller/controllers/clusterextension_controller_test.go index 437f62dce..20761aec9 100644 --- a/internal/operator-controller/controllers/clusterextension_controller_test.go +++ b/internal/operator-controller/controllers/clusterextension_controller_test.go @@ -381,7 +381,8 @@ func TestClusterExtensionServiceAccountNotFound(t *testing.T) { require.Equal(t, ctrl.Result{}, res) require.Error(t, err) - require.IsType(t, &authentication.ServiceAccountNotFoundError{}, err) + var saErr *authentication.ServiceAccountNotFoundError + require.ErrorAs(t, err, &saErr) t.Log("By fetching updated cluster extension after reconcile") require.NoError(t, cl.Get(ctx, extKey, clusterExtension)) diff --git a/internal/operator-controller/controllers/clusterextensionrevision_controller.go b/internal/operator-controller/controllers/clusterextensionrevision_controller.go index 7e98faa65..ec035eee7 100644 --- a/internal/operator-controller/controllers/clusterextensionrevision_controller.go +++ b/internal/operator-controller/controllers/clusterextensionrevision_controller.go @@ -16,7 +16,6 @@ import ( "k8s.io/apimachinery/pkg/api/equality" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/sets" @@ -34,10 +33,10 @@ import ( "sigs.k8s.io/controller-runtime/pkg/source" ocv1 "github.com/operator-framework/operator-controller/api/v1" + "github.com/operator-framework/operator-controller/internal/operator-controller/labels" ) const ( - ClusterExtensionRevisionOwnerLabel = "olm.operatorframework.io/owner" clusterExtensionRevisionTeardownFinalizer = "olm.operatorframework.io/teardown" ) @@ -116,7 +115,17 @@ func checkForUnexpectedClusterExtensionRevisionFieldChange(a, b ocv1.ClusterExte func (c *ClusterExtensionRevisionReconciler) reconcile(ctx context.Context, rev *ocv1.ClusterExtensionRevision) (ctrl.Result, error) { l := log.FromContext(ctx) - revision, opts, previous := toBoxcutterRevision(rev) + revision, opts, err := c.toBoxcutterRevision(ctx, rev) + if err != nil { + meta.SetStatusCondition(&rev.Status.Conditions, metav1.Condition{ + Type: ocv1.ClusterExtensionRevisionTypeAvailable, + Status: metav1.ConditionFalse, + Reason: ocv1.ClusterExtensionRevisionReasonReconcileFailure, + Message: err.Error(), + ObservedGeneration: rev.Generation, + }) + return ctrl.Result{}, fmt.Errorf("converting to boxcutter revision: %v", err) + } if !rev.DeletionTimestamp.IsZero() || rev.Spec.LifecycleState == ocv1.ClusterExtensionRevisionLifecycleStateArchived { return c.teardown(ctx, rev, revision) @@ -150,7 +159,8 @@ func (c *ClusterExtensionRevisionReconciler) reconcile(ctx context.Context, rev rres, err := c.RevisionEngine.Reconcile(ctx, *revision, opts...) if err != nil { if rres != nil { - l.Error(err, "revision reconcile failed", "report", rres.String()) + l.Error(err, "revision reconcile failed") + l.V(1).Info("reconcile failure report", "report", rres.String()) } else { l.Error(err, "revision reconcile failed") } @@ -218,10 +228,14 @@ func (c *ClusterExtensionRevisionReconciler) reconcile(ctx context.Context, rev //nolint:nestif if rres.IsComplete() { - // Archive other revisions. + // Archive previous revisions + previous, err := c.listPreviousRevisions(ctx, rev) + if err != nil { + return ctrl.Result{}, fmt.Errorf("listing previous revisions: %v", err) + } for _, a := range previous { patch := []byte(`{"spec":{"lifecycleState":"Archived"}}`) - if err := c.Client.Patch(ctx, a, client.RawPatch(types.MergePatchType, patch)); err != nil { + if err := c.Client.Patch(ctx, client.Object(a), client.RawPatch(types.MergePatchType, patch)); err != nil { // TODO: It feels like an error here needs to propagate to a status _somewhere_. // Not sure the current CER makes sense? But it also feels off to set the CE // status from outside the CE reconciler. @@ -307,7 +321,8 @@ func (c *ClusterExtensionRevisionReconciler) teardown(ctx context.Context, rev * tres, err := c.RevisionEngine.Teardown(ctx, *revision) if err != nil { if tres != nil { - l.Error(err, "revision teardown failed", "report", tres.String()) + l.Error(err, "revision teardown failed") + l.V(1).Info("teardown failure report", "report", tres.String()) } else { l.Error(err, "revision teardown failed") } @@ -440,18 +455,57 @@ func (c *ClusterExtensionRevisionReconciler) removeFinalizer(ctx context.Context return nil } -func toBoxcutterRevision(rev *ocv1.ClusterExtensionRevision) (*boxcutter.Revision, []boxcutter.RevisionReconcileOption, []client.Object) { - previous := make([]client.Object, 0, len(rev.Spec.Previous)) - for _, specPrevious := range rev.Spec.Previous { - prev := &unstructured.Unstructured{} - prev.SetName(specPrevious.Name) - prev.SetUID(specPrevious.UID) - prev.SetGroupVersionKind(ocv1.GroupVersion.WithKind(ocv1.ClusterExtensionRevisionKind)) - previous = append(previous, prev) +// listPreviousRevisions returns active revisions belonging to the same ClusterExtension with lower revision numbers. +// Filters out the current revision, archived revisions, deleting revisions, and revisions with equal or higher numbers. +func (c *ClusterExtensionRevisionReconciler) listPreviousRevisions(ctx context.Context, rev *ocv1.ClusterExtensionRevision) ([]*ocv1.ClusterExtensionRevision, error) { + ownerLabel, ok := rev.Labels[labels.OwnerNameKey] + if !ok { + // No owner label means this revision isn't properly labeled - return empty list + return nil, nil + } + + revList := &ocv1.ClusterExtensionRevisionList{} + if err := c.TrackingCache.List(ctx, revList, client.MatchingLabels{ + labels.OwnerNameKey: ownerLabel, + }); err != nil { + return nil, fmt.Errorf("listing revisions: %w", err) + } + + previous := make([]*ocv1.ClusterExtensionRevision, 0, len(revList.Items)) + for i := range revList.Items { + r := &revList.Items[i] + if r.Name == rev.Name { + continue + } + // Skip archived or deleting revisions + if r.Spec.LifecycleState == ocv1.ClusterExtensionRevisionLifecycleStateArchived || + !r.DeletionTimestamp.IsZero() { + continue + } + // Only include revisions with lower revision numbers (actual previous revisions) + if r.Spec.Revision >= rev.Spec.Revision { + continue + } + previous = append(previous, r) + } + + return previous, nil +} + +func (c *ClusterExtensionRevisionReconciler) toBoxcutterRevision(ctx context.Context, rev *ocv1.ClusterExtensionRevision) (*boxcutter.Revision, []boxcutter.RevisionReconcileOption, error) { + previous, err := c.listPreviousRevisions(ctx, rev) + if err != nil { + return nil, nil, fmt.Errorf("listing previous revisions: %w", err) + } + + // Convert to []client.Object for boxcutter + previousObjs := make([]client.Object, len(previous)) + for i, rev := range previous { + previousObjs[i] = rev } opts := []boxcutter.RevisionReconcileOption{ - boxcutter.WithPreviousOwners(previous), + boxcutter.WithPreviousOwners(previousObjs), boxcutter.WithProbe(boxcutter.ProgressProbeType, probing.And{ deploymentProbe, statefulSetProbe, crdProbe, issuerProbe, certProbe, }), @@ -467,12 +521,12 @@ func toBoxcutterRevision(rev *ocv1.ClusterExtensionRevision) (*boxcutter.Revisio for _, specObj := range specPhase.Objects { obj := specObj.Object.DeepCopy() - labels := obj.GetLabels() - if labels == nil { - labels = map[string]string{} + objLabels := obj.GetLabels() + if objLabels == nil { + objLabels = map[string]string{} } - labels[ClusterExtensionRevisionOwnerLabel] = rev.Labels[ClusterExtensionRevisionOwnerLabel] - obj.SetLabels(labels) + objLabels[labels.OwnerNameKey] = rev.Labels[labels.OwnerNameKey] + obj.SetLabels(objLabels) switch specObj.CollisionProtection { case ocv1.CollisionProtectionIfNoController, ocv1.CollisionProtectionNone: @@ -488,7 +542,7 @@ func toBoxcutterRevision(rev *ocv1.ClusterExtensionRevision) (*boxcutter.Revisio if rev.Spec.LifecycleState == ocv1.ClusterExtensionRevisionLifecycleStatePaused { opts = append(opts, boxcutter.WithPaused{}) } - return r, opts, previous + return r, opts, nil } var ( diff --git a/internal/operator-controller/controllers/clusterextensionrevision_controller_internal_test.go b/internal/operator-controller/controllers/clusterextensionrevision_controller_internal_test.go new file mode 100644 index 000000000..b1c966892 --- /dev/null +++ b/internal/operator-controller/controllers/clusterextensionrevision_controller_internal_test.go @@ -0,0 +1,237 @@ +package controllers + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/sets" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/source" + + ocv1 "github.com/operator-framework/operator-controller/api/v1" + "github.com/operator-framework/operator-controller/internal/operator-controller/labels" +) + +func Test_ClusterExtensionRevisionReconciler_listPreviousRevisions(t *testing.T) { + testScheme := runtime.NewScheme() + require.NoError(t, ocv1.AddToScheme(testScheme)) + + for _, tc := range []struct { + name string + existingObjs func() []client.Object + currentRev string + expectedRevs []string + }{ + { + // Scenario: + // - Three revisions belong to the same owner. + // - We ask for previous revisions of rev-2. + // - Only revisions with lower revision numbers are returned (rev-1). + // - Higher revision numbers (rev-3) are excluded. + name: "should skip current revision when listing previous", + existingObjs: func() []client.Object { + ext := newTestClusterExtensionInternal() + rev1 := newTestClusterExtensionRevisionInternal(t, "rev-1") + rev2 := newTestClusterExtensionRevisionInternal(t, "rev-2") + rev3 := newTestClusterExtensionRevisionInternal(t, "rev-3") + require.NoError(t, controllerutil.SetControllerReference(ext, rev1, testScheme)) + require.NoError(t, controllerutil.SetControllerReference(ext, rev2, testScheme)) + require.NoError(t, controllerutil.SetControllerReference(ext, rev3, testScheme)) + return []client.Object{ext, rev1, rev2, rev3} + }, + currentRev: "rev-2", + expectedRevs: []string{"rev-1"}, + }, + { + // Scenario: + // - One sibling is archived already. + // - The caller should not get archived items. + // - Only active siblings are returned. + name: "should drop archived revisions when listing previous", + existingObjs: func() []client.Object { + ext := newTestClusterExtensionInternal() + rev1 := newTestClusterExtensionRevisionInternal(t, "rev-1") + rev2 := newTestClusterExtensionRevisionInternal(t, "rev-2") + rev2.Spec.LifecycleState = ocv1.ClusterExtensionRevisionLifecycleStateArchived + rev3 := newTestClusterExtensionRevisionInternal(t, "rev-3") + require.NoError(t, controllerutil.SetControllerReference(ext, rev1, testScheme)) + require.NoError(t, controllerutil.SetControllerReference(ext, rev2, testScheme)) + require.NoError(t, controllerutil.SetControllerReference(ext, rev3, testScheme)) + return []client.Object{ext, rev1, rev2, rev3} + }, + currentRev: "rev-3", + expectedRevs: []string{"rev-1"}, + }, + { + // Scenario: + // - One sibling is being deleted. + // - We list previous revisions. + // - The deleting one is filtered out. + name: "should drop deleting revisions when listing previous", + existingObjs: func() []client.Object { + ext := newTestClusterExtensionInternal() + rev1 := newTestClusterExtensionRevisionInternal(t, "rev-1") + rev2 := newTestClusterExtensionRevisionInternal(t, "rev-2") + rev2.Finalizers = []string{"test-finalizer"} + rev2.DeletionTimestamp = &metav1.Time{Time: time.Now()} + rev3 := newTestClusterExtensionRevisionInternal(t, "rev-3") + require.NoError(t, controllerutil.SetControllerReference(ext, rev1, testScheme)) + require.NoError(t, controllerutil.SetControllerReference(ext, rev2, testScheme)) + require.NoError(t, controllerutil.SetControllerReference(ext, rev3, testScheme)) + return []client.Object{ext, rev1, rev2, rev3} + }, + currentRev: "rev-3", + expectedRevs: []string{"rev-1"}, + }, + { + // Scenario: + // - Two different owners have revisions. + // - The owner label is used as the filter. + // - Only siblings with the same owner come back. + name: "should only include revisions matching owner label", + existingObjs: func() []client.Object { + ext := newTestClusterExtensionInternal() + ext2 := newTestClusterExtensionInternal() + ext2.Name = "test-ext-2" + ext2.UID = "test-ext-2" + + rev1 := newTestClusterExtensionRevisionInternal(t, "rev-1") + rev2 := newTestClusterExtensionRevisionInternal(t, "rev-2") + rev2.Labels[labels.OwnerNameKey] = "test-ext-2" + rev3 := newTestClusterExtensionRevisionInternal(t, "rev-3") + require.NoError(t, controllerutil.SetControllerReference(ext, rev1, testScheme)) + require.NoError(t, controllerutil.SetControllerReference(ext2, rev2, testScheme)) + require.NoError(t, controllerutil.SetControllerReference(ext, rev3, testScheme)) + return []client.Object{ext, ext2, rev1, rev2, rev3} + }, + currentRev: "rev-3", + expectedRevs: []string{"rev-1"}, + }, + { + // Scenario: + // - The revision has no owner label. + // - Without the label we skip the lookup. + // - The function returns an empty list. + name: "should return empty list when owner label missing", + existingObjs: func() []client.Object { + ext := newTestClusterExtensionInternal() + rev1 := newTestClusterExtensionRevisionInternal(t, "rev-1") + delete(rev1.Labels, labels.OwnerNameKey) + require.NoError(t, controllerutil.SetControllerReference(ext, rev1, testScheme)) + return []client.Object{ext, rev1} + }, + currentRev: "rev-1", + expectedRevs: []string{}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + testClient := fake.NewClientBuilder(). + WithScheme(testScheme). + WithObjects(tc.existingObjs()...). + Build() + + reconciler := &ClusterExtensionRevisionReconciler{ + Client: testClient, + TrackingCache: &mockTrackingCacheInternal{client: testClient}, + } + + currentRev := &ocv1.ClusterExtensionRevision{} + err := testClient.Get(t.Context(), client.ObjectKey{Name: tc.currentRev}, currentRev) + require.NoError(t, err) + + previous, err := reconciler.listPreviousRevisions(t.Context(), currentRev) + require.NoError(t, err) + + var names []string + for _, rev := range previous { + names = append(names, rev.GetName()) + } + + require.ElementsMatch(t, tc.expectedRevs, names) + }) + } +} + +func newTestClusterExtensionInternal() *ocv1.ClusterExtension { + return &ocv1.ClusterExtension{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-ext", + UID: "test-ext", + }, + Spec: ocv1.ClusterExtensionSpec{ + Namespace: "some-namespace", + ServiceAccount: ocv1.ServiceAccountReference{ + Name: "some-sa", + }, + Source: ocv1.SourceConfig{ + SourceType: "Catalog", + Catalog: &ocv1.CatalogFilter{ + PackageName: "my-package", + }, + }, + }, + } +} + +func newTestClusterExtensionRevisionInternal(t *testing.T, name string) *ocv1.ClusterExtensionRevision { + t.Helper() + + // Extract revision number from name (e.g., "rev-1" -> 1, "test-ext-10" -> 10) + revNum := ExtractRevisionNumber(t, name) + + rev := &ocv1.ClusterExtensionRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + UID: types.UID(name), + Generation: int64(1), + Labels: map[string]string{ + labels.OwnerNameKey: "test-ext", + }, + }, + Spec: ocv1.ClusterExtensionRevisionSpec{ + Revision: revNum, + Phases: []ocv1.ClusterExtensionRevisionPhase{ + { + Name: "everything", + Objects: []ocv1.ClusterExtensionRevisionObject{}, + }, + }, + }, + } + rev.SetGroupVersionKind(ocv1.GroupVersion.WithKind("ClusterExtensionRevision")) + return rev +} + +type mockTrackingCacheInternal struct { + client client.Client +} + +func (m *mockTrackingCacheInternal) Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + return m.client.Get(ctx, key, obj, opts...) +} + +func (m *mockTrackingCacheInternal) List(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { + return m.client.List(ctx, list, opts...) +} + +func (m *mockTrackingCacheInternal) Free(ctx context.Context, user client.Object) error { + return nil +} + +func (m *mockTrackingCacheInternal) Watch(ctx context.Context, user client.Object, gvks sets.Set[schema.GroupVersionKind]) error { + return nil +} + +func (m *mockTrackingCacheInternal) Source(h handler.EventHandler, predicates ...predicate.Predicate) source.Source { + return nil +} diff --git a/internal/operator-controller/controllers/clusterextensionrevision_controller_test.go b/internal/operator-controller/controllers/clusterextensionrevision_controller_test.go index 873a6cc74..e88051537 100644 --- a/internal/operator-controller/controllers/clusterextensionrevision_controller_test.go +++ b/internal/operator-controller/controllers/clusterextensionrevision_controller_test.go @@ -29,6 +29,7 @@ import ( ocv1 "github.com/operator-framework/operator-controller/api/v1" "github.com/operator-framework/operator-controller/internal/operator-controller/controllers" + "github.com/operator-framework/operator-controller/internal/operator-controller/labels" ) func Test_ClusterExtensionRevisionReconciler_Reconcile_RevisionProgression(t *testing.T) { @@ -49,7 +50,7 @@ func Test_ClusterExtensionRevisionReconciler_Reconcile_RevisionProgression(t *te revisionResult: mockRevisionResult{}, existingObjs: func() []client.Object { ext := newTestClusterExtension() - rev1 := newTestClusterExtensionRevision(clusterExtensionRevisionName) + rev1 := newTestClusterExtensionRevision(t, clusterExtensionRevisionName) require.NoError(t, controllerutil.SetControllerReference(ext, rev1, testScheme)) return []client.Object{ext, rev1} }, @@ -67,7 +68,7 @@ func Test_ClusterExtensionRevisionReconciler_Reconcile_RevisionProgression(t *te revisionResult: mockRevisionResult{}, existingObjs: func() []client.Object { ext := newTestClusterExtension() - rev1 := newTestClusterExtensionRevision(clusterExtensionRevisionName) + rev1 := newTestClusterExtensionRevision(t, clusterExtensionRevisionName) require.NoError(t, controllerutil.SetControllerReference(ext, rev1, testScheme)) return []client.Object{ext, rev1} }, @@ -156,7 +157,7 @@ func Test_ClusterExtensionRevisionReconciler_Reconcile_RevisionProgression(t *te }, existingObjs: func() []client.Object { ext := newTestClusterExtension() - rev1 := newTestClusterExtensionRevision(clusterExtensionRevisionName) + rev1 := newTestClusterExtensionRevision(t, clusterExtensionRevisionName) require.NoError(t, controllerutil.SetControllerReference(ext, rev1, testScheme)) return []client.Object{ext, rev1} }, @@ -181,7 +182,7 @@ func Test_ClusterExtensionRevisionReconciler_Reconcile_RevisionProgression(t *te }, existingObjs: func() []client.Object { ext := newTestClusterExtension() - rev1 := newTestClusterExtensionRevision(clusterExtensionRevisionName) + rev1 := newTestClusterExtensionRevision(t, clusterExtensionRevisionName) require.NoError(t, controllerutil.SetControllerReference(ext, rev1, testScheme)) return []client.Object{ext, rev1} }, @@ -206,7 +207,7 @@ func Test_ClusterExtensionRevisionReconciler_Reconcile_RevisionProgression(t *te }, existingObjs: func() []client.Object { ext := newTestClusterExtension() - rev1 := newTestClusterExtensionRevision(clusterExtensionRevisionName) + rev1 := newTestClusterExtensionRevision(t, clusterExtensionRevisionName) require.NoError(t, controllerutil.SetControllerReference(ext, rev1, testScheme)) meta.SetStatusCondition(&rev1.Status.Conditions, metav1.Condition{ Type: ocv1.TypeProgressing, @@ -234,7 +235,7 @@ func Test_ClusterExtensionRevisionReconciler_Reconcile_RevisionProgression(t *te }, existingObjs: func() []client.Object { ext := newTestClusterExtension() - rev1 := newTestClusterExtensionRevision(clusterExtensionRevisionName) + rev1 := newTestClusterExtensionRevision(t, clusterExtensionRevisionName) require.NoError(t, controllerutil.SetControllerReference(ext, rev1, testScheme)) return []client.Object{ext, rev1} }, @@ -266,22 +267,14 @@ func Test_ClusterExtensionRevisionReconciler_Reconcile_RevisionProgression(t *te }, existingObjs: func() []client.Object { ext := newTestClusterExtension() - prevRev1 := newTestClusterExtensionRevision("prev-rev-1") + prevRev1 := newTestClusterExtensionRevision(t, "prev-rev-1") require.NoError(t, controllerutil.SetControllerReference(ext, prevRev1, testScheme)) - prevRev2 := newTestClusterExtensionRevision("prev-rev-2") + prevRev2 := newTestClusterExtensionRevision(t, "prev-rev-2") require.NoError(t, controllerutil.SetControllerReference(ext, prevRev2, testScheme)) - rev1 := newTestClusterExtensionRevision("test-ext-1") - rev1.Spec.Previous = []ocv1.ClusterExtensionRevisionPrevious{ - { - Name: "prev-rev-1", - UID: "prev-rev-1", - }, { - Name: "prev-rev-2", - UID: "prev-rev-2", - }, - } - require.NoError(t, controllerutil.SetControllerReference(ext, rev1, testScheme)) - return []client.Object{ext, prevRev1, prevRev2, rev1} + currentRev := newTestClusterExtensionRevision(t, clusterExtensionRevisionName) + currentRev.Spec.Revision = 3 + require.NoError(t, controllerutil.SetControllerReference(ext, currentRev, testScheme)) + return []client.Object{ext, prevRev1, prevRev2, currentRev} }, validate: func(t *testing.T, c client.Client) { rev := &ocv1.ClusterExtensionRevision{} @@ -315,7 +308,7 @@ func Test_ClusterExtensionRevisionReconciler_Reconcile_RevisionProgression(t *te return tc.revisionResult, nil }, }, - TrackingCache: &mockTrackingCache{}, + TrackingCache: &mockTrackingCache{client: testClient}, }).Reconcile(t.Context(), ctrl.Request{ NamespacedName: types.NamespacedName{ Name: clusterExtensionRevisionName, @@ -413,7 +406,7 @@ func Test_ClusterExtensionRevisionReconciler_Reconcile_ValidationError_Retries(t } { t.Run(tc.name, func(t *testing.T) { ext := newTestClusterExtension() - rev1 := newTestClusterExtensionRevision(clusterExtensionRevisionName) + rev1 := newTestClusterExtensionRevision(t, clusterExtensionRevisionName) require.NoError(t, controllerutil.SetControllerReference(ext, rev1, testScheme)) // create extension and cluster extension @@ -431,7 +424,7 @@ func Test_ClusterExtensionRevisionReconciler_Reconcile_ValidationError_Retries(t return tc.revisionResult, nil }, }, - TrackingCache: &mockTrackingCache{}, + TrackingCache: &mockTrackingCache{client: testClient}, }).Reconcile(t.Context(), ctrl.Request{ NamespacedName: types.NamespacedName{ Name: clusterExtensionRevisionName, @@ -467,7 +460,7 @@ func Test_ClusterExtensionRevisionReconciler_Reconcile_Deletion(t *testing.T) { name: "teardown finalizer is removed", revisionResult: mockRevisionResult{}, existingObjs: func() []client.Object { - rev1 := newTestClusterExtensionRevision(clusterExtensionRevisionName) + rev1 := newTestClusterExtensionRevision(t, clusterExtensionRevisionName) rev1.Finalizers = []string{ "olm.operatorframework.io/teardown", } @@ -490,7 +483,7 @@ func Test_ClusterExtensionRevisionReconciler_Reconcile_Deletion(t *testing.T) { revisionResult: mockRevisionResult{}, existingObjs: func() []client.Object { ext := newTestClusterExtension() - rev1 := newTestClusterExtensionRevision(clusterExtensionRevisionName) + rev1 := newTestClusterExtensionRevision(t, clusterExtensionRevisionName) rev1.Finalizers = []string{ "olm.operatorframework.io/teardown", } @@ -520,7 +513,7 @@ func Test_ClusterExtensionRevisionReconciler_Reconcile_Deletion(t *testing.T) { revisionResult: mockRevisionResult{}, existingObjs: func() []client.Object { ext := newTestClusterExtension() - rev1 := newTestClusterExtensionRevision(clusterExtensionRevisionName) + rev1 := newTestClusterExtensionRevision(t, clusterExtensionRevisionName) rev1.Finalizers = []string{ "olm.operatorframework.io/teardown", } @@ -549,7 +542,7 @@ func Test_ClusterExtensionRevisionReconciler_Reconcile_Deletion(t *testing.T) { revisionResult: mockRevisionResult{}, existingObjs: func() []client.Object { ext := newTestClusterExtension() - rev1 := newTestClusterExtensionRevision(clusterExtensionRevisionName) + rev1 := newTestClusterExtensionRevision(t, clusterExtensionRevisionName) rev1.Finalizers = []string{ "olm.operatorframework.io/teardown", } @@ -583,7 +576,7 @@ func Test_ClusterExtensionRevisionReconciler_Reconcile_Deletion(t *testing.T) { revisionResult: mockRevisionResult{}, existingObjs: func() []client.Object { ext := newTestClusterExtension() - rev1 := newTestClusterExtensionRevision(clusterExtensionRevisionName) + rev1 := newTestClusterExtensionRevision(t, clusterExtensionRevisionName) rev1.Finalizers = []string{ "olm.operatorframework.io/teardown", } @@ -619,7 +612,7 @@ func Test_ClusterExtensionRevisionReconciler_Reconcile_Deletion(t *testing.T) { revisionResult: mockRevisionResult{}, existingObjs: func() []client.Object { ext := newTestClusterExtension() - rev1 := newTestClusterExtensionRevision(clusterExtensionRevisionName) + rev1 := newTestClusterExtensionRevision(t, clusterExtensionRevisionName) rev1.Finalizers = []string{ "olm.operatorframework.io/teardown", } @@ -661,7 +654,7 @@ func Test_ClusterExtensionRevisionReconciler_Reconcile_Deletion(t *testing.T) { }, teardown: tc.revisionEngineTeardownFn(t), }, - TrackingCache: &mockTrackingCache{}, + TrackingCache: &mockTrackingCache{client: testClient}, }).Reconcile(t.Context(), ctrl.Request{ NamespacedName: types.NamespacedName{ Name: clusterExtensionRevisionName, @@ -703,14 +696,23 @@ func newTestClusterExtension() *ocv1.ClusterExtension { } } -func newTestClusterExtensionRevision(name string) *ocv1.ClusterExtensionRevision { +func newTestClusterExtensionRevision(t *testing.T, name string) *ocv1.ClusterExtensionRevision { + t.Helper() + + // Extract revision number from name (e.g., "rev-1" -> 1, "test-ext-10" -> 10) + revNum := controllers.ExtractRevisionNumber(t, name) + return &ocv1.ClusterExtensionRevision{ ObjectMeta: metav1.ObjectMeta{ Name: name, UID: types.UID(name), Generation: int64(1), + Labels: map[string]string{ + labels.OwnerNameKey: "test-ext", + }, }, Spec: ocv1.ClusterExtensionRevisionSpec{ + Revision: revNum, Phases: []ocv1.ClusterExtensionRevisionPhase{ { Name: "everything", @@ -879,14 +881,16 @@ func (m mockRevisionTeardownResult) String() string { return m.string } -type mockTrackingCache struct{} +type mockTrackingCache struct { + client client.Client +} func (m *mockTrackingCache) Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { - panic("not implemented") + return m.client.Get(ctx, key, obj, opts...) } func (m *mockTrackingCache) List(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { - panic("not implemented") + return m.client.List(ctx, list, opts...) } func (m *mockTrackingCache) Source(handler handler.EventHandler, predicates ...predicate.Predicate) source.Source { diff --git a/internal/operator-controller/controllers/testhelpers_test.go b/internal/operator-controller/controllers/testhelpers_test.go new file mode 100644 index 000000000..8b5aeaa96 --- /dev/null +++ b/internal/operator-controller/controllers/testhelpers_test.go @@ -0,0 +1,35 @@ +package controllers + +import ( + "strconv" + "strings" + "testing" +) + +// ExtractRevisionNumber parses the revision number from a test revision name. +// It expects names to end with a numeric revision (e.g., "rev-1", "test-ext-10"). +// Returns 1 as default if parsing fails, which is suitable for test fixtures. +// +// Note: This is a test helper and silently defaults to 1 for convenience. +// Callers are responsible for ensuring the name suffix matches the Spec.Revision value they intend to set; +// this function does not enforce or validate such a match. +func ExtractRevisionNumber(t *testing.T, name string) int64 { + t.Helper() + + parts := strings.Split(name, "-") + if len(parts) == 0 { + t.Logf("warning: revision name %q has no parts, defaulting to revision 1", name) + return 1 + } + + lastPart := parts[len(parts)-1] + revNum, err := strconv.ParseInt(lastPart, 10, 64) + if err != nil { + t.Logf("warning: revision name %q does not end with a numeric revision (got %q), defaulting to revision 1. "+ + "Test helper names should follow the pattern 'prefix-' (e.g., 'rev-1', 'test-ext-10')", + name, lastPart) + return 1 + } + + return revNum +} diff --git a/internal/operator-controller/rukpak/bundle/config.go b/internal/operator-controller/rukpak/bundle/config.go deleted file mode 100644 index 7ec4bfdf0..000000000 --- a/internal/operator-controller/rukpak/bundle/config.go +++ /dev/null @@ -1,124 +0,0 @@ -package bundle - -import ( - "encoding/json" - "errors" - "fmt" - "strings" - - "k8s.io/apimachinery/pkg/util/sets" - "k8s.io/apimachinery/pkg/util/validation" - "sigs.k8s.io/yaml" - - "github.com/operator-framework/api/pkg/operators/v1alpha1" -) - -type Config struct { - WatchNamespace *string `json:"watchNamespace"` -} - -// UnmarshalConfig returns a deserialized *bundle.Config based on bytes and validated -// against rv1 and the desired install namespaces. It will error if: -// - rv is nil -// - bytes is not a valid YAML/JSON object -// - bytes is a valid YAML/JSON object but does not follow the registry+v1 schema -// - if bytes is nil, a nil *bundle.Config is returned with no error -func UnmarshalConfig(bytes []byte, rv1 RegistryV1, installNamespace string) (*Config, error) { - if bytes == nil { - return nil, nil - } - - bundleConfig := &Config{} - if err := yaml.UnmarshalStrict(bytes, bundleConfig); err != nil { - return nil, fmt.Errorf("error unmarshalling registry+v1 configuration: %w", formatUnmarshalError(err)) - } - - // collect bundle install modes - bundleInstallModeSet := sets.New(rv1.CSV.Spec.InstallModes...) - - if err := validateConfig(bundleConfig, installNamespace, bundleInstallModeSet); err != nil { - return nil, fmt.Errorf("error unmarshalling registry+v1 configuration: %w", err) - } - - return bundleConfig, nil -} - -// validateConfig validates a *bundle.Config against the bundle's supported install modes and the user-give installNamespace. -func validateConfig(config *Config, installNamespace string, bundleInstallModeSet sets.Set[v1alpha1.InstallMode]) error { - // no config, no problem - if config == nil { - return nil - } - - // if the bundle does not support the watchNamespace configuration and it is set, treat it like any unknown field - if config.WatchNamespace != nil && !isWatchNamespaceConfigSupported(bundleInstallModeSet) { - return errors.New(`unknown field "watchNamespace"`) - } - - // if watchNamespace is required then ensure that it is set - if config.WatchNamespace == nil && isWatchNamespaceConfigRequired(bundleInstallModeSet) { - return errors.New(`required field "watchNamespace" is missing`) - } - - // if watchNamespace is set then ensure it is a valid namespace - if config.WatchNamespace != nil { - if errs := validation.IsDNS1123Subdomain(*config.WatchNamespace); len(errs) > 0 { - return fmt.Errorf("invalid 'watchNamespace' %q: namespace must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character", *config.WatchNamespace) - } - } - - // only accept install namespace if OwnNamespace install mode is supported - if config.WatchNamespace != nil && *config.WatchNamespace == installNamespace && - !bundleInstallModeSet.Has(v1alpha1.InstallMode{Type: v1alpha1.InstallModeTypeOwnNamespace, Supported: true}) { - return fmt.Errorf("invalid 'watchNamespace' %q: must not be install namespace (%s)", *config.WatchNamespace, installNamespace) - } - - // only accept non-install namespace is SingleNamespace is supported - if config.WatchNamespace != nil && *config.WatchNamespace != installNamespace && - !bundleInstallModeSet.Has(v1alpha1.InstallMode{Type: v1alpha1.InstallModeTypeSingleNamespace, Supported: true}) { - return fmt.Errorf("invalid 'watchNamespace' %q: must be install namespace (%s)", *config.WatchNamespace, installNamespace) - } - - return nil -} - -// isWatchNamespaceConfigSupported returns true when the bundle exposes a watchNamespace configuration. This happens when: -// - SingleNamespace and/or OwnNamespace install modes are supported -func isWatchNamespaceConfigSupported(bundleInstallModeSet sets.Set[v1alpha1.InstallMode]) bool { - return bundleInstallModeSet.HasAny( - v1alpha1.InstallMode{Type: v1alpha1.InstallModeTypeSingleNamespace, Supported: true}, - v1alpha1.InstallMode{Type: v1alpha1.InstallModeTypeOwnNamespace, Supported: true}, - ) -} - -// isWatchNamespaceConfigRequired returns true if the watchNamespace configuration is required. This happens when -// AllNamespaces install mode is not supported and SingleNamespace and/or OwnNamespace is supported -func isWatchNamespaceConfigRequired(bundleInstallModeSet sets.Set[v1alpha1.InstallMode]) bool { - return isWatchNamespaceConfigSupported(bundleInstallModeSet) && - !bundleInstallModeSet.Has(v1alpha1.InstallMode{Type: v1alpha1.InstallModeTypeAllNamespaces, Supported: true}) -} - -// formatUnmarshalError format JSON unmarshal errors to be more readable -func formatUnmarshalError(err error) error { - var unmarshalErr *json.UnmarshalTypeError - if errors.As(err, &unmarshalErr) { - if unmarshalErr.Field == "" { - return errors.New("input is not a valid JSON object") - } else { - return fmt.Errorf("invalid value type for field %q: expected %q but got %q", unmarshalErr.Field, unmarshalErr.Type.String(), unmarshalErr.Value) - } - } - - // unwrap error until the core and process it - for { - unwrapped := errors.Unwrap(err) - if unwrapped == nil { - // usually the errors present in the form json: or yaml: - // we want to extract if we can - errMessageComponents := strings.Split(err.Error(), ":") - coreErrMessage := strings.TrimSpace(errMessageComponents[len(errMessageComponents)-1]) - return errors.New(coreErrMessage) - } - err = unwrapped - } -} diff --git a/internal/operator-controller/rukpak/bundle/config_test.go b/internal/operator-controller/rukpak/bundle/config_test.go deleted file mode 100644 index a6c4b394a..000000000 --- a/internal/operator-controller/rukpak/bundle/config_test.go +++ /dev/null @@ -1,310 +0,0 @@ -package bundle_test - -import ( - "testing" - - "github.com/stretchr/testify/require" - "k8s.io/utils/ptr" - - "github.com/operator-framework/api/pkg/operators/v1alpha1" - - "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/bundle" - "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/util/testing/clusterserviceversion" -) - -func Test_UnmarshalConfig(t *testing.T) { - for _, tc := range []struct { - name string - rawConfig []byte - supportedInstallModes []v1alpha1.InstallModeType - installNamespace string - expectedErrMessage string - expectedConfig *bundle.Config - }{ - { - name: "returns nil for nil config", - rawConfig: nil, - expectedConfig: nil, - }, - { - name: "accepts json config", - supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeSingleNamespace}, - rawConfig: []byte(`{"watchNamespace": "some-namespace"}`), - expectedConfig: &bundle.Config{ - WatchNamespace: ptr.To("some-namespace"), - }, - }, - { - name: "accepts yaml config", - supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeSingleNamespace}, - rawConfig: []byte(`watchNamespace: some-namespace`), - expectedConfig: &bundle.Config{ - WatchNamespace: ptr.To("some-namespace"), - }, - }, - { - name: "rejects invalid json", - supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeSingleNamespace}, - rawConfig: []byte(`{"hello`), - expectedErrMessage: `error unmarshalling registry+v1 configuration: found unexpected end of stream`, - }, - { - name: "rejects valid json that isn't of object type", - supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeSingleNamespace}, - rawConfig: []byte(`true`), - expectedErrMessage: `error unmarshalling registry+v1 configuration: input is not a valid JSON object`, - }, - { - name: "rejects additional fields", - supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeSingleNamespace}, - rawConfig: []byte(`somekey: somevalue`), - expectedErrMessage: `error unmarshalling registry+v1 configuration: unknown field "somekey"`, - }, - { - name: "rejects valid json but invalid registry+v1", - supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeSingleNamespace}, - rawConfig: []byte(`{"watchNamespace": {"hello": "there"}}`), - expectedErrMessage: `error unmarshalling registry+v1 configuration: invalid value type for field "watchNamespace": expected "string" but got "object"`, - }, - { - name: "rejects bad namespace format", - supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeSingleNamespace}, - rawConfig: []byte(`{"watchNamespace": "bad-Namespace-"}`), - expectedErrMessage: "invalid 'watchNamespace' \"bad-Namespace-\": namespace must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character", - }, - { - name: "rejects with unknown field when install modes {AllNamespaces}", - supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeAllNamespaces}, - rawConfig: []byte(`{"watchNamespace": "some-namespace"}`), - expectedErrMessage: "unknown field \"watchNamespace\"", - }, - { - name: "rejects with unknown field when install modes {MultiNamespace}", - supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeMultiNamespace}, - rawConfig: []byte(`{"watchNamespace": "some-namespace"}`), - expectedErrMessage: "unknown field \"watchNamespace\"", - }, - { - name: "reject with unknown field when install modes {AllNamespaces, MultiNamespace}", - supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeAllNamespaces, v1alpha1.InstallModeTypeMultiNamespace}, - rawConfig: []byte(`{"watchNamespace": "some-namespace"}`), - expectedErrMessage: "unknown field \"watchNamespace\"", - }, - { - name: "reject with required field when install modes {OwnNamespace} and watchNamespace is null", - supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeOwnNamespace}, - rawConfig: []byte(`{"watchNamespace": null}`), - expectedErrMessage: "required field \"watchNamespace\" is missing", - }, - { - name: "reject with required field when install modes {OwnNamespace} and watchNamespace is missing", - supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeOwnNamespace}, - rawConfig: []byte(`{}`), - expectedErrMessage: "required field \"watchNamespace\" is missing", - }, - { - name: "reject with required field when install modes {MultiNamespace, OwnNamespace} and watchNamespace is null", - supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeMultiNamespace, v1alpha1.InstallModeTypeOwnNamespace}, - rawConfig: []byte(`{"watchNamespace": null}`), - expectedErrMessage: "required field \"watchNamespace\" is missing", - }, - { - name: "reject with required field when install modes {MultiNamespace, OwnNamespace} and watchNamespace is missing", - supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeMultiNamespace, v1alpha1.InstallModeTypeOwnNamespace}, - rawConfig: []byte(`{}`), - expectedErrMessage: "required field \"watchNamespace\" is missing", - }, - { - name: "accepts when install modes {SingleNamespace} and watchNamespace != install namespace", - supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeSingleNamespace}, - rawConfig: []byte(`{"watchNamespace": "some-namespace"}`), - expectedConfig: &bundle.Config{ - WatchNamespace: ptr.To("some-namespace"), - }, - }, - { - name: "accepts when install modes {AllNamespaces, SingleNamespace} and watchNamespace != install namespace", - supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeAllNamespaces, v1alpha1.InstallModeTypeSingleNamespace}, - rawConfig: []byte(`{"watchNamespace": "some-namespace"}`), - expectedConfig: &bundle.Config{ - WatchNamespace: ptr.To("some-namespace"), - }, - }, - { - name: "accepts when install modes {MultiNamespace, SingleNamespace} and watchNamespace != install namespace", - supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeMultiNamespace, v1alpha1.InstallModeTypeSingleNamespace}, - rawConfig: []byte(`{"watchNamespace": "some-namespace"}`), - expectedConfig: &bundle.Config{ - WatchNamespace: ptr.To("some-namespace"), - }, - }, - { - name: "accepts when install modes {OwnNamespace, SingleNamespace} and watchNamespace != install namespace", - supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeOwnNamespace, v1alpha1.InstallModeTypeSingleNamespace}, - rawConfig: []byte(`{"watchNamespace": "some-namespace"}`), - installNamespace: "not-namespace", - expectedConfig: &bundle.Config{ - WatchNamespace: ptr.To("some-namespace"), - }, - }, - { - name: "rejects when install modes {SingleNamespace} and watchNamespace == install namespace", - supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeSingleNamespace}, - rawConfig: []byte(`{"watchNamespace": "some-namespace"}`), - installNamespace: "some-namespace", - expectedErrMessage: "invalid 'watchNamespace' \"some-namespace\": must not be install namespace (some-namespace)", - }, - { - name: "rejects when install modes {AllNamespaces, SingleNamespace} and watchNamespace == install namespace", - supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeAllNamespaces, v1alpha1.InstallModeTypeSingleNamespace}, - rawConfig: []byte(`{"watchNamespace": "some-namespace"}`), - installNamespace: "some-namespace", - expectedErrMessage: "invalid 'watchNamespace' \"some-namespace\": must not be install namespace (some-namespace)", - }, - { - name: "rejects when install modes {MultiNamespace, SingleNamespace} and watchNamespace == install namespace", - supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeMultiNamespace, v1alpha1.InstallModeTypeSingleNamespace}, - rawConfig: []byte(`{"watchNamespace": "some-namespace"}`), - installNamespace: "some-namespace", - expectedErrMessage: "invalid 'watchNamespace' \"some-namespace\": must not be install namespace (some-namespace)", - }, - { - name: "accepts when install modes {AllNamespaces, OwnNamespace} and watchNamespace == install namespace", - supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeAllNamespaces, v1alpha1.InstallModeTypeOwnNamespace}, - rawConfig: []byte(`{"watchNamespace": "some-namespace"}`), - installNamespace: "some-namespace", - expectedConfig: &bundle.Config{ - WatchNamespace: ptr.To("some-namespace"), - }, - }, - { - name: "accepts when install modes {OwnNamespace, SingleNamespace} and watchNamespace == install namespace", - supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeOwnNamespace, v1alpha1.InstallModeTypeSingleNamespace}, - rawConfig: []byte(`{"watchNamespace": "some-namespace"}`), - installNamespace: "some-namespace", - expectedConfig: &bundle.Config{ - WatchNamespace: ptr.To("some-namespace"), - }, - }, - { - name: "rejects when install modes {AllNamespaces, OwnNamespace} and watchNamespace != install namespace", - supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeAllNamespaces, v1alpha1.InstallModeTypeOwnNamespace}, - rawConfig: []byte(`{"watchNamespace": "some-namespace"}`), - installNamespace: "not-some-namespace", - expectedErrMessage: "invalid 'watchNamespace' \"some-namespace\": must be install namespace (not-some-namespace)", - }, - { - name: "rejects with required field error when install modes {SingleNamespace} and watchNamespace is nil", - supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeSingleNamespace}, - rawConfig: []byte(`{"watchNamespace": null}`), - installNamespace: "not-some-namespace", - expectedErrMessage: "required field \"watchNamespace\" is missing", - }, - { - name: "rejects with required field error when install modes {SingleNamespace, OwnNamespace} and watchNamespace is nil", - supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeSingleNamespace, v1alpha1.InstallModeTypeOwnNamespace}, - rawConfig: []byte(`{"watchNamespace": null}`), - installNamespace: "not-some-namespace", - expectedErrMessage: "required field \"watchNamespace\" is missing", - }, - { - name: "rejects with required field error when install modes {SingleNamespace, MultiNamespace} and watchNamespace is nil", - supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeSingleNamespace, v1alpha1.InstallModeTypeMultiNamespace}, - rawConfig: []byte(`{"watchNamespace": null}`), - installNamespace: "not-some-namespace", - expectedErrMessage: "required field \"watchNamespace\" is missing", - }, - { - name: "rejects with required field error when install modes {SingleNamespace} and watchNamespace is missing", - supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeSingleNamespace}, - rawConfig: []byte(`{}`), - installNamespace: "not-some-namespace", - expectedErrMessage: "required field \"watchNamespace\" is missing", - }, - { - name: "rejects with required field error when install modes {SingleNamespace, OwnNamespace} and watchNamespace is missing", - supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeSingleNamespace, v1alpha1.InstallModeTypeOwnNamespace}, - rawConfig: []byte(`{}`), - installNamespace: "not-some-namespace", - expectedErrMessage: "required field \"watchNamespace\" is missing", - }, - { - name: "rejects with required field error when install modes {SingleNamespace, MultiNamespace} and watchNamespace is missing", - supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeSingleNamespace, v1alpha1.InstallModeTypeMultiNamespace}, - rawConfig: []byte(`{}`), - installNamespace: "not-some-namespace", - expectedErrMessage: "required field \"watchNamespace\" is missing", - }, - { - name: "rejects with required field error when install modes {SingleNamespace, OwnNamespace, MultiNamespace} and watchNamespace is nil", - supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeSingleNamespace, v1alpha1.InstallModeTypeOwnNamespace, v1alpha1.InstallModeTypeMultiNamespace}, - rawConfig: []byte(`{"watchNamespace": null}`), - installNamespace: "not-some-namespace", - expectedErrMessage: "required field \"watchNamespace\" is missing", - }, - { - name: "accepts null watchNamespace when install modes {AllNamespaces, OwnNamespace} and watchNamespace is nil", - supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeAllNamespaces, v1alpha1.InstallModeTypeOwnNamespace}, - rawConfig: []byte(`{"watchNamespace": null}`), - installNamespace: "not-some-namespace", - expectedConfig: &bundle.Config{ - WatchNamespace: nil, - }, - }, - { - name: "accepts null watchNamespace when install modes {AllNamespaces, OwnNamespace, MultiNamespace} and watchNamespace is nil", - supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeAllNamespaces, v1alpha1.InstallModeTypeOwnNamespace, v1alpha1.InstallModeTypeMultiNamespace}, - rawConfig: []byte(`{"watchNamespace": null}`), - installNamespace: "not-some-namespace", - expectedConfig: &bundle.Config{ - WatchNamespace: nil, - }, - }, - { - name: "accepts no watchNamespace when install modes {AllNamespaces, OwnNamespace} and watchNamespace is nil", - supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeAllNamespaces, v1alpha1.InstallModeTypeOwnNamespace}, - rawConfig: []byte(`{}`), - installNamespace: "not-some-namespace", - expectedConfig: &bundle.Config{ - WatchNamespace: nil, - }, - }, - { - name: "accepts no watchNamespace when install modes {AllNamespaces, OwnNamespace, MultiNamespace} and watchNamespace is nil", - supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeAllNamespaces, v1alpha1.InstallModeTypeOwnNamespace, v1alpha1.InstallModeTypeMultiNamespace}, - rawConfig: []byte(`{}`), - installNamespace: "not-some-namespace", - expectedConfig: &bundle.Config{ - WatchNamespace: nil, - }, - }, - { - name: "rejects with format error when install modes are {SingleNamespace, OwnNamespace} and watchNamespace is ''", - supportedInstallModes: []v1alpha1.InstallModeType{v1alpha1.InstallModeTypeSingleNamespace, v1alpha1.InstallModeTypeOwnNamespace}, - rawConfig: []byte(`{"watchNamespace": ""}`), - installNamespace: "not-some-namespace", - expectedErrMessage: "invalid 'watchNamespace' \"\": namespace must consist of lower case alphanumeric characters", - }, - } { - t.Run(tc.name, func(t *testing.T) { - var rv1 bundle.RegistryV1 - if tc.supportedInstallModes != nil { - rv1 = bundle.RegistryV1{ - CSV: clusterserviceversion.Builder(). - WithName("test-operator"). - WithInstallModeSupportFor(tc.supportedInstallModes...). - Build(), - } - } - - config, err := bundle.UnmarshalConfig(tc.rawConfig, rv1, tc.installNamespace) - require.Equal(t, tc.expectedConfig, config) - if tc.expectedErrMessage != "" { - require.Error(t, err) - require.Contains(t, err.Error(), tc.expectedErrMessage) - } else { - require.NoError(t, err) - } - }) - } -} diff --git a/internal/operator-controller/rukpak/bundle/registryv1.go b/internal/operator-controller/rukpak/bundle/registryv1.go index cffc374e9..7fc3e3e18 100644 --- a/internal/operator-controller/rukpak/bundle/registryv1.go +++ b/internal/operator-controller/rukpak/bundle/registryv1.go @@ -3,8 +3,11 @@ package bundle import ( apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/util/sets" "github.com/operator-framework/api/pkg/operators/v1alpha1" + + "github.com/operator-framework/operator-controller/internal/operator-controller/config" ) const ( @@ -17,3 +20,134 @@ type RegistryV1 struct { CRDs []apiextensionsv1.CustomResourceDefinition Others []unstructured.Unstructured } + +// GetConfigSchema builds a validation schema based on what install modes the operator supports. +// +// For registry+v1 bundles, we look at the CSV's install modes and generate a schema +// that matches. For example, if the operator only supports OwnNamespace mode, we'll +// require the user to provide a watchNamespace that equals the install namespace. +func (rv1 *RegistryV1) GetConfigSchema() (map[string]any, error) { + installModes := sets.New(rv1.CSV.Spec.InstallModes...) + return buildBundleConfigSchema(installModes) +} + +// buildBundleConfigSchema creates validation rules based on what the operator supports. +// +// Examples of how install modes affect validation: +// - AllNamespaces only: user can't set watchNamespace (operator watches everything) +// - OwnNamespace only: user must set watchNamespace to the install namespace +// - SingleNamespace only: user must set watchNamespace to a different namespace +// - AllNamespaces + OwnNamespace: user can optionally set watchNamespace +func buildBundleConfigSchema(installModes sets.Set[v1alpha1.InstallMode]) (map[string]any, error) { + schema := map[string]any{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, // Reject unknown fields (catches typos and misconfigurations) + } + + properties := map[string]any{} + var required []any + + // Add watchNamespace property if the bundle supports it + if isWatchNamespaceConfigurable(installModes) { + watchNSProperty, isRequired := buildWatchNamespaceProperty(installModes) + properties["watchNamespace"] = watchNSProperty + if isRequired { + required = append(required, "watchNamespace") + } + } + + schema["properties"] = properties + if len(required) > 0 { + schema["required"] = required + } + + return schema, nil +} + +// buildWatchNamespaceProperty creates the validation rules for the watchNamespace field. +// +// The rules depend on what install modes are supported: +// - AllNamespaces supported: watchNamespace is optional (can be null) +// - Only Single/Own supported: watchNamespace is required +// - Only OwnNamespace: must equal install namespace +// - Only SingleNamespace: must be different from install namespace +// +// Returns the validation rules and whether the field is required. +func buildWatchNamespaceProperty(installModes sets.Set[v1alpha1.InstallMode]) (map[string]any, bool) { + watchNSProperty := map[string]any{ + "description": "The namespace that the operator should watch for custom resources", + } + + hasOwnNamespace := installModes.Has(v1alpha1.InstallMode{Type: v1alpha1.InstallModeTypeOwnNamespace, Supported: true}) + hasSingleNamespace := installModes.Has(v1alpha1.InstallMode{Type: v1alpha1.InstallModeTypeSingleNamespace, Supported: true}) + + format := selectNamespaceFormat(hasOwnNamespace, hasSingleNamespace) + + if isWatchNamespaceConfigRequired(installModes) { + watchNSProperty["type"] = "string" + if format != "" { + watchNSProperty["format"] = format + } + return watchNSProperty, true + } + + // allow null or valid namespace string + stringSchema := map[string]any{ + "type": "string", + } + if format != "" { + stringSchema["format"] = format + } + // Convert to []any for JSON schema compatibility + watchNSProperty["anyOf"] = []any{ + map[string]any{"type": "null"}, + stringSchema, + } + + return watchNSProperty, false +} + +// selectNamespaceFormat picks which namespace constraint to apply. +// +// - OwnNamespace only: watchNamespace must equal install namespace +// - SingleNamespace only: watchNamespace must be different from install namespace +// - Both or neither: no constraint, any namespace name is valid +func selectNamespaceFormat(hasOwnNamespace, hasSingleNamespace bool) string { + if hasOwnNamespace && !hasSingleNamespace { + return config.FormatOwnNamespaceInstallMode + } + if hasSingleNamespace && !hasOwnNamespace { + return config.FormatSingleNamespaceInstallMode + } + return "" // No format constraint needed for generic case +} + +// isWatchNamespaceConfigurable checks if the user can set a watchNamespace. +// +// Returns true if: +// - SingleNamespace is supported (user picks a namespace to watch) +// - OwnNamespace is supported (user sets watchNamespace to the install namespace) +// +// Returns false if: +// - Only AllNamespaces is supported (operator always watches everything) +func isWatchNamespaceConfigurable(installModes sets.Set[v1alpha1.InstallMode]) bool { + return installModes.HasAny( + v1alpha1.InstallMode{Type: v1alpha1.InstallModeTypeSingleNamespace, Supported: true}, + v1alpha1.InstallMode{Type: v1alpha1.InstallModeTypeOwnNamespace, Supported: true}, + ) +} + +// isWatchNamespaceConfigRequired checks if the user must provide a watchNamespace. +// +// Returns true (required) when: +// - Only OwnNamespace is supported +// - Only SingleNamespace is supported +// - Both OwnNamespace and SingleNamespace are supported +// +// Returns false (optional) when: +// - AllNamespaces is supported (user can leave it unset to watch all namespaces) +func isWatchNamespaceConfigRequired(installModes sets.Set[v1alpha1.InstallMode]) bool { + return isWatchNamespaceConfigurable(installModes) && + !installModes.Has(v1alpha1.InstallMode{Type: v1alpha1.InstallModeTypeAllNamespaces, Supported: true}) +} diff --git a/internal/shared/util/cache/transform.go b/internal/shared/util/cache/transform.go deleted file mode 100644 index 50a553039..000000000 --- a/internal/shared/util/cache/transform.go +++ /dev/null @@ -1,91 +0,0 @@ -package cache - -import ( - "maps" - - toolscache "k8s.io/client-go/tools/cache" - crcache "sigs.k8s.io/controller-runtime/pkg/cache" - "sigs.k8s.io/controller-runtime/pkg/client" -) - -// stripAnnotations removes memory-heavy annotations that aren't needed for controller operations. -func stripAnnotations(obj interface{}) (interface{}, error) { - if metaObj, ok := obj.(client.Object); ok { - // Remove the last-applied-configuration annotation which can be very large - // Clone the annotations map to avoid modifying shared references - annotations := metaObj.GetAnnotations() - if annotations != nil { - annotations = maps.Clone(annotations) - delete(annotations, "kubectl.kubernetes.io/last-applied-configuration") - if len(annotations) == 0 { - metaObj.SetAnnotations(nil) - } else { - metaObj.SetAnnotations(annotations) - } - } - } - return obj, nil -} - -// StripManagedFieldsAndAnnotations returns a cache transform function that removes -// memory-heavy fields that aren't needed for controller operations. -// This significantly reduces memory usage in informer caches by removing: -// - Managed fields (can be several KB per object) -// - kubectl.kubernetes.io/last-applied-configuration annotation (can be very large) -// -// Use this function as a DefaultTransform in controller-runtime cache.Options -// to reduce memory overhead across all cached objects. -// -// Example: -// -// cacheOptions := cache.Options{ -// DefaultTransform: cacheutil.StripManagedFieldsAndAnnotations(), -// } -func StripManagedFieldsAndAnnotations() toolscache.TransformFunc { - // Use controller-runtime's built-in TransformStripManagedFields and compose it - // with our custom annotation stripping transform - managedFieldsTransform := crcache.TransformStripManagedFields() - - return func(obj interface{}) (interface{}, error) { - // First strip managed fields using controller-runtime's transform - obj, err := managedFieldsTransform(obj) - if err != nil { - return obj, err - } - - // Then strip the large annotations - return stripAnnotations(obj) - } -} - -// StripAnnotations returns a cache transform function that removes -// memory-heavy annotation fields that aren't needed for controller operations. -// This significantly reduces memory usage in informer caches by removing: -// - kubectl.kubernetes.io/last-applied-configuration annotation (can be very large) -// -// Use this function as a DefaultTransform in controller-runtime cache.Options -// to reduce memory overhead across all cached objects. -// -// Example: -// -// cacheOptions := cache.Options{ -// DefaultTransform: cacheutil.StripAnnotations(), -// } -func StripAnnotations() toolscache.TransformFunc { - return func(obj interface{}) (interface{}, error) { - // Strip the large annotations - return stripAnnotations(obj) - } -} - -// ApplyStripAnnotationsTransform applies the strip transform directly to an object. -// This is a convenience function for cases where you need to strip fields -// from an object outside of the cache transform context. -// -// Note: This function never returns an error in practice, but returns error -// to satisfy the TransformFunc interface. -func ApplyStripAnnotationsTransform(obj client.Object) error { - transform := StripAnnotations() - _, err := transform(obj) - return err -} diff --git a/internal/shared/util/image/pull_test.go b/internal/shared/util/image/pull_test.go index 45a04062f..ca2cfa50a 100644 --- a/internal/shared/util/image/pull_test.go +++ b/internal/shared/util/image/pull_test.go @@ -298,8 +298,15 @@ func buildSourceContextFunc(t *testing.T, ref reference.Named) func(context.Cont require.NoError(t, enc.Encode(registriesConf)) require.NoError(t, f.Close()) + // Create an insecure policy for testing to override any system-level policy + // that might reject unsigned images + policyPath := filepath.Join(configDir, "policy.json") + insecurePolicy := `{"default":[{"type":"insecureAcceptAnything"}]}` + require.NoError(t, os.WriteFile(policyPath, []byte(insecurePolicy), 0600)) + return &types.SystemContext{ SystemRegistriesConfPath: registriesConfPath, + SignaturePolicyPath: policyPath, }, nil } } diff --git a/manifests/experimental-e2e.yaml b/manifests/experimental-e2e.yaml index db03c11a8..672830225 100644 --- a/manifests/experimental-e2e.yaml +++ b/manifests/experimental-e2e.yaml @@ -678,6 +678,10 @@ spec: description: |- CollisionProtection controls whether OLM can adopt and modify objects already existing on the cluster or even owned by another controller. + enum: + - Prevent + - IfNoController + - None type: string object: type: object @@ -698,27 +702,6 @@ spec: x-kubernetes-validations: - message: phases is immutable rule: self == oldSelf || oldSelf.size() == 0 - previous: - description: Previous references previous revisions that objects can - be adopted from. - items: - properties: - name: - type: string - uid: - description: |- - UID is a type that holds unique ID values, including UUIDs. Because we - don't ONLY use UUIDs, this is an alias to string. Being a type captures - intent and helps make sure that UIDs and names do not get conflated. - type: string - required: - - name - - uid - type: object - type: array - x-kubernetes-validations: - - message: previous is immutable - rule: self == oldSelf revision: description: |- Revision is a sequence number representing a specific revision of the ClusterExtension instance. diff --git a/manifests/experimental.yaml b/manifests/experimental.yaml index 664f8599c..199838eac 100644 --- a/manifests/experimental.yaml +++ b/manifests/experimental.yaml @@ -643,6 +643,10 @@ spec: description: |- CollisionProtection controls whether OLM can adopt and modify objects already existing on the cluster or even owned by another controller. + enum: + - Prevent + - IfNoController + - None type: string object: type: object @@ -663,27 +667,6 @@ spec: x-kubernetes-validations: - message: phases is immutable rule: self == oldSelf || oldSelf.size() == 0 - previous: - description: Previous references previous revisions that objects can - be adopted from. - items: - properties: - name: - type: string - uid: - description: |- - UID is a type that holds unique ID values, including UUIDs. Because we - don't ONLY use UUIDs, this is an alias to string. Being a type captures - intent and helps make sure that UIDs and names do not get conflated. - type: string - required: - - name - - uid - type: object - type: array - x-kubernetes-validations: - - message: previous is immutable - rule: self == oldSelf revision: description: |- Revision is a sequence number representing a specific revision of the ClusterExtension instance. diff --git a/requirements.txt b/requirements.txt index 9ef710e34..72686f75c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ Babel==2.17.0 beautifulsoup4==4.14.2 certifi==2025.11.12 charset-normalizer==3.4.4 -click==8.3.0 +click==8.3.1 colorama==0.4.6 cssselect==1.3.0 ghp-import==2.1.0 diff --git a/test/e2e/single_namespace_support_test.go b/test/e2e/single_namespace_support_test.go index 7e8fe7bd5..2c3b825a1 100644 --- a/test/e2e/single_namespace_support_test.go +++ b/test/e2e/single_namespace_support_test.go @@ -148,7 +148,7 @@ func TestClusterExtensionSingleNamespaceSupport(t *testing.T) { require.NotNil(ct, cond) require.Equal(ct, metav1.ConditionTrue, cond.Status) require.Equal(ct, ocv1.ReasonRetrying, cond.Reason) - require.Contains(ct, cond.Message, "required field \"watchNamespace\" is missing") + require.Contains(ct, cond.Message, `required field "watchNamespace" is missing`) }, pollDuration, pollInterval) t.Log("By updating the ClusterExtension configuration with a watchNamespace") @@ -296,7 +296,7 @@ func TestClusterExtensionOwnNamespaceSupport(t *testing.T) { require.NotNil(ct, cond) require.Equal(ct, metav1.ConditionTrue, cond.Status) require.Equal(ct, ocv1.ReasonRetrying, cond.Reason) - require.Contains(ct, cond.Message, "required field \"watchNamespace\" is missing") + require.Contains(ct, cond.Message, `required field "watchNamespace" is missing`) }, pollDuration, pollInterval) t.Log("By updating the ClusterExtension configuration with a watchNamespace other than the install namespace") @@ -318,7 +318,9 @@ func TestClusterExtensionOwnNamespaceSupport(t *testing.T) { require.NotNil(ct, cond) require.Equal(ct, metav1.ConditionTrue, cond.Status) require.Equal(ct, ocv1.ReasonRetrying, cond.Reason) - require.Contains(ct, cond.Message, fmt.Sprintf("invalid 'watchNamespace' \"some-namespace\": must be install namespace (%s)", clusterExtension.Spec.Namespace)) + require.Contains(ct, cond.Message, "invalid ClusterExtension configuration") + require.Contains(ct, cond.Message, fmt.Sprintf("watchNamespace must be \"%s\"", clusterExtension.Spec.Namespace)) + require.Contains(ct, cond.Message, "OwnNamespace install mode") }, pollDuration, pollInterval) t.Log("By updating the ClusterExtension configuration with a watchNamespace = install namespace") diff --git a/vendor/github.com/google/renameio/v2/.golangci.yml b/vendor/github.com/google/renameio/v2/.golangci.yml index abfb6ca0a..579e22b54 100644 --- a/vendor/github.com/google/renameio/v2/.golangci.yml +++ b/vendor/github.com/google/renameio/v2/.golangci.yml @@ -1,5 +1,24 @@ +version: "2" linters: disable: - - errcheck + - errcheck + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + paths: + - third_party$ + - builtin$ + - examples$ +formatters: enable: - - gofmt + - gofmt + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/vendor/github.com/google/renameio/v2/README.md b/vendor/github.com/google/renameio/v2/README.md index 703884c26..8caed1c7a 100644 --- a/vendor/github.com/google/renameio/v2/README.md +++ b/vendor/github.com/google/renameio/v2/README.md @@ -1,6 +1,6 @@ [![Build Status](https://github.com/google/renameio/workflows/Test/badge.svg)](https://github.com/google/renameio/actions?query=workflow%3ATest) -[![PkgGoDev](https://pkg.go.dev/badge/github.com/google/renameio)](https://pkg.go.dev/github.com/google/renameio) -[![Go Report Card](https://goreportcard.com/badge/github.com/google/renameio)](https://goreportcard.com/report/github.com/google/renameio) +[![PkgGoDev](https://pkg.go.dev/badge/github.com/google/renameio/v2)](https://pkg.go.dev/github.com/google/renameio/v2) +[![Go Report Card](https://goreportcard.com/badge/github.com/google/renameio/v2)](https://goreportcard.com/report/github.com/google/renameio/v2) The `renameio` Go package provides a way to atomically create or replace a file or symbolic link. diff --git a/vendor/github.com/google/renameio/v2/option.go b/vendor/github.com/google/renameio/v2/option.go index f825f6cf9..a86906f4c 100644 --- a/vendor/github.com/google/renameio/v2/option.go +++ b/vendor/github.com/google/renameio/v2/option.go @@ -77,3 +77,12 @@ func WithExistingPermissions() Option { c.attemptPermCopy = true }) } + +// WithReplaceOnClose causes PendingFile.Close() to actually call +// CloseAtomicallyReplace(). This means PendingFile implements io.Closer while +// maintaining atomicity per default. +func WithReplaceOnClose() Option { + return optionFunc(func(c *config) { + c.renameOnClose = true + }) +} diff --git a/vendor/github.com/google/renameio/v2/tempfile.go b/vendor/github.com/google/renameio/v2/tempfile.go index edc3e9871..98114e539 100644 --- a/vendor/github.com/google/renameio/v2/tempfile.go +++ b/vendor/github.com/google/renameio/v2/tempfile.go @@ -114,9 +114,10 @@ func tempDir(dir, dest string) string { type PendingFile struct { *os.File - path string - done bool - closed bool + path string + done bool + closed bool + replaceOnClose bool } // Cleanup is a no-op if CloseAtomicallyReplace succeeded, and otherwise closes @@ -131,7 +132,7 @@ func (t *PendingFile) Cleanup() error { // reporting, there is nothing the caller can recover here. var closeErr error if !t.closed { - closeErr = t.Close() + closeErr = t.File.Close() } if err := os.Remove(t.Name()); err != nil { return err @@ -159,7 +160,7 @@ func (t *PendingFile) CloseAtomicallyReplace() error { return err } t.closed = true - if err := t.Close(); err != nil { + if err := t.File.Close(); err != nil { return err } if err := os.Rename(t.Name(), t.path); err != nil { @@ -169,6 +170,15 @@ func (t *PendingFile) CloseAtomicallyReplace() error { return nil } +// Close closes the file. By default it just calls Close() on the underlying file. For PendingFiles created with +// WithReplaceOnClose it calls CloseAtomicallyReplace() instead. +func (t *PendingFile) Close() error { + if t.replaceOnClose { + return t.CloseAtomicallyReplace() + } + return t.File.Close() +} + // TempFile creates a temporary file destined to atomically creating or // replacing the destination file at path. // @@ -189,6 +199,7 @@ type config struct { attemptPermCopy bool ignoreUmask bool chmod *os.FileMode + renameOnClose bool } // NewPendingFile creates a temporary file destined to atomically creating or @@ -244,7 +255,7 @@ func NewPendingFile(path string, opts ...Option) (*PendingFile, error) { } } - return &PendingFile{File: f, path: cfg.path}, nil + return &PendingFile{File: f, path: cfg.path, replaceOnClose: cfg.renameOnClose}, nil } // Symlink wraps os.Symlink, replacing an existing symlink with the same name diff --git a/vendor/modules.txt b/vendor/modules.txt index d03425e03..837fb4db7 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -444,7 +444,7 @@ github.com/google/go-containerregistry/pkg/v1/tarball github.com/google/go-containerregistry/pkg/v1/types # github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d ## explicit; go 1.24.0 -# github.com/google/renameio/v2 v2.0.0 +# github.com/google/renameio/v2 v2.0.1 ## explicit; go 1.13 github.com/google/renameio/v2 # github.com/google/uuid v1.6.0 @@ -710,7 +710,7 @@ github.com/prometheus/client_golang/prometheus/promhttp/internal # github.com/prometheus/client_model v0.6.2 ## explicit; go 1.22.0 github.com/prometheus/client_model/go -# github.com/prometheus/common v0.67.2 +# github.com/prometheus/common v0.67.3 ## explicit; go 1.24.0 github.com/prometheus/common/expfmt github.com/prometheus/common/model