diff --git a/NOTICE.txt b/NOTICE.txt index bb1d9e2f21c..a6e1a704040 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -6789,20 +6789,118 @@ Contents of probable licence file $GOMODCACHE/sigs.k8s.io/controller-tools@v0.21 limitations under the License. +-------------------------------------------------------------------------------- +Module : sigs.k8s.io/yaml +Version : v1.6.0 +Time : 2025-07-24T18:12:28Z +Licence : Apache-2.0 +Contents of probable licence file $GOMODCACHE/sigs.k8s.io/yaml@v1.6.0/LICENSE: -================================================================================ -Indirect dependencies +The MIT License (MIT) +Copyright (c) 2014 Sam Ghods --------------------------------------------------------------------------------- -Module : cel.dev/expr -Version : v0.25.1 -Time : 2025-11-08T22:07:56Z -Licence : Apache-2.0 +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -Contents of probable licence file $GOMODCACHE/cel.dev/expr@v0.25.1/LICENSE: +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +Copyright (c) 2012 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +# The forked go-yaml.v3 library under this project is covered by two +different licenses (MIT and Apache): +#### MIT License #### + +The following files were ported to Go from C files of libyaml, and thus +are still covered by their original MIT license, with the additional +copyright staring in 2011 when the project was ported over: + + apic.go emitterc.go parserc.go readerc.go scannerc.go + writerc.go yamlh.go yamlprivateh.go + +Copyright (c) 2006-2010 Kirill Simonov +Copyright (c) 2006-2011 Kirill Simonov + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +### Apache License ### + +All the remaining project files are covered by the Apache license: + +Copyright (c) 2011-2019 Canonical Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +# The forked go-yaml.v2 library under the project is covered by an +Apache license: Apache License Version 2.0, January 2004 @@ -6984,7 +7082,7 @@ Contents of probable licence file $GOMODCACHE/cel.dev/expr@v0.25.1/LICENSE: APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" + boilerplate notice, with the fields enclosed by brackets "{}" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a @@ -6992,7 +7090,7 @@ Contents of probable licence file $GOMODCACHE/cel.dev/expr@v0.25.1/LICENSE: same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright {yyyy} {name of copyright owner} Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -7007,13 +7105,19 @@ Contents of probable licence file $GOMODCACHE/cel.dev/expr@v0.25.1/LICENSE: limitations under the License. + + +================================================================================ +Indirect dependencies + + -------------------------------------------------------------------------------- -Module : cloud.google.com/go -Version : v0.123.0 -Time : 2025-09-22T16:26:20Z +Module : cel.dev/expr +Version : v0.25.1 +Time : 2025-11-08T22:07:56Z Licence : Apache-2.0 -Contents of probable licence file $GOMODCACHE/cloud.google.com/go@v0.123.0/LICENSE: +Contents of probable licence file $GOMODCACHE/cel.dev/expr@v0.25.1/LICENSE: Apache License @@ -7220,12 +7324,12 @@ Contents of probable licence file $GOMODCACHE/cloud.google.com/go@v0.123.0/LICEN -------------------------------------------------------------------------------- -Module : cloud.google.com/go/auth/oauth2adapt -Version : v0.2.8 -Time : 2025-03-20T15:18:21Z +Module : cloud.google.com/go +Version : v0.123.0 +Time : 2025-09-22T16:26:20Z Licence : Apache-2.0 -Contents of probable licence file $GOMODCACHE/cloud.google.com/go/auth/oauth2adapt@v0.2.8/LICENSE: +Contents of probable licence file $GOMODCACHE/cloud.google.com/go@v0.123.0/LICENSE: Apache License @@ -7432,12 +7536,12 @@ Contents of probable licence file $GOMODCACHE/cloud.google.com/go/auth/oauth2ada -------------------------------------------------------------------------------- -Module : cloud.google.com/go/compute/metadata -Version : v0.9.0 -Time : 2025-09-24T19:41:55Z +Module : cloud.google.com/go/auth/oauth2adapt +Version : v0.2.8 +Time : 2025-03-20T15:18:21Z Licence : Apache-2.0 -Contents of probable licence file $GOMODCACHE/cloud.google.com/go/compute/metadata@v0.9.0/LICENSE: +Contents of probable licence file $GOMODCACHE/cloud.google.com/go/auth/oauth2adapt@v0.2.8/LICENSE: Apache License @@ -7644,12 +7748,12 @@ Contents of probable licence file $GOMODCACHE/cloud.google.com/go/compute/metada -------------------------------------------------------------------------------- -Module : cloud.google.com/go/iam -Version : v1.11.0 -Time : 2026-05-07T21:21:33Z +Module : cloud.google.com/go/compute/metadata +Version : v0.9.0 +Time : 2025-09-24T19:41:55Z Licence : Apache-2.0 -Contents of probable licence file $GOMODCACHE/cloud.google.com/go/iam@v1.11.0/LICENSE: +Contents of probable licence file $GOMODCACHE/cloud.google.com/go/compute/metadata@v0.9.0/LICENSE: Apache License @@ -7856,12 +7960,12 @@ Contents of probable licence file $GOMODCACHE/cloud.google.com/go/iam@v1.11.0/LI -------------------------------------------------------------------------------- -Module : cloud.google.com/go/logging -Version : v1.18.0 +Module : cloud.google.com/go/iam +Version : v1.11.0 Time : 2026-05-07T21:21:33Z Licence : Apache-2.0 -Contents of probable licence file $GOMODCACHE/cloud.google.com/go/logging@v1.18.0/LICENSE: +Contents of probable licence file $GOMODCACHE/cloud.google.com/go/iam@v1.11.0/LICENSE: Apache License @@ -8068,12 +8172,12 @@ Contents of probable licence file $GOMODCACHE/cloud.google.com/go/logging@v1.18. -------------------------------------------------------------------------------- -Module : cloud.google.com/go/longrunning -Version : v1.0.0 -Time : 2026-05-11T16:07:01Z +Module : cloud.google.com/go/logging +Version : v1.18.0 +Time : 2026-05-07T21:21:33Z Licence : Apache-2.0 -Contents of probable licence file $GOMODCACHE/cloud.google.com/go/longrunning@v1.0.0/LICENSE: +Contents of probable licence file $GOMODCACHE/cloud.google.com/go/logging@v1.18.0/LICENSE: Apache License @@ -8280,12 +8384,12 @@ Contents of probable licence file $GOMODCACHE/cloud.google.com/go/longrunning@v1 -------------------------------------------------------------------------------- -Module : cloud.google.com/go/monitoring -Version : v1.29.0 -Time : 2026-05-07T21:21:33Z +Module : cloud.google.com/go/longrunning +Version : v1.0.0 +Time : 2026-05-11T16:07:01Z Licence : Apache-2.0 -Contents of probable licence file $GOMODCACHE/cloud.google.com/go/monitoring@v1.29.0/LICENSE: +Contents of probable licence file $GOMODCACHE/cloud.google.com/go/longrunning@v1.0.0/LICENSE: Apache License @@ -8492,12 +8596,12 @@ Contents of probable licence file $GOMODCACHE/cloud.google.com/go/monitoring@v1. -------------------------------------------------------------------------------- -Module : cloud.google.com/go/trace -Version : v1.16.0 +Module : cloud.google.com/go/monitoring +Version : v1.29.0 Time : 2026-05-07T21:21:33Z Licence : Apache-2.0 -Contents of probable licence file $GOMODCACHE/cloud.google.com/go/trace@v1.16.0/LICENSE: +Contents of probable licence file $GOMODCACHE/cloud.google.com/go/monitoring@v1.29.0/LICENSE: Apache License @@ -8704,57 +8808,269 @@ Contents of probable licence file $GOMODCACHE/cloud.google.com/go/trace@v1.16.0/ -------------------------------------------------------------------------------- -Module : github.com/Azure/azure-sdk-for-go/sdk/azcore -Version : v1.22.0 -Time : 2026-06-04T17:26:12Z -Licence : MIT +Module : cloud.google.com/go/trace +Version : v1.16.0 +Time : 2026-05-07T21:21:33Z +Licence : Apache-2.0 -Contents of probable licence file $GOMODCACHE/github.com/!azure/azure-sdk-for-go/sdk/azcore@v1.22.0/LICENSE.txt: +Contents of probable licence file $GOMODCACHE/cloud.google.com/go/trace@v1.16.0/LICENSE: -MIT License -Copyright (c) Microsoft Corporation. + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. + 1. Definitions. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. --------------------------------------------------------------------------------- -Module : github.com/Azure/azure-sdk-for-go/sdk/azidentity -Version : v1.13.1 -Time : 2025-11-10T16:14:28Z -Licence : MIT + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. -Contents of probable licence file $GOMODCACHE/github.com/!azure/azure-sdk-for-go/sdk/azidentity@v1.13.1/LICENSE.txt: + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. -MIT License + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. -Copyright (c) Microsoft Corporation. + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + +-------------------------------------------------------------------------------- +Module : github.com/Azure/azure-sdk-for-go/sdk/azcore +Version : v1.22.0 +Time : 2026-06-04T17:26:12Z +Licence : MIT + +Contents of probable licence file $GOMODCACHE/github.com/!azure/azure-sdk-for-go/sdk/azcore@v1.22.0/LICENSE.txt: + +MIT License + +Copyright (c) Microsoft Corporation. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE + + +-------------------------------------------------------------------------------- +Module : github.com/Azure/azure-sdk-for-go/sdk/azidentity +Version : v1.13.1 +Time : 2025-11-10T16:14:28Z +Licence : MIT + +Contents of probable licence file $GOMODCACHE/github.com/!azure/azure-sdk-for-go/sdk/azidentity@v1.13.1/LICENSE.txt: + +MIT License + +Copyright (c) Microsoft Corporation. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, @@ -35032,320 +35348,4 @@ Contents of probable licence file $GOMODCACHE/sigs.k8s.io/structured-merge-diff/ limitations under the License. --------------------------------------------------------------------------------- -Module : sigs.k8s.io/yaml -Version : v1.6.0 -Time : 2025-07-24T18:12:28Z -Licence : Apache-2.0 - -Contents of probable licence file $GOMODCACHE/sigs.k8s.io/yaml@v1.6.0/LICENSE: - -The MIT License (MIT) - -Copyright (c) 2014 Sam Ghods - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - -Copyright (c) 2012 The Go Authors. All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: - - * Redistributions of source code must retain the above copyright -notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above -copyright notice, this list of conditions and the following disclaimer -in the documentation and/or other materials provided with the -distribution. - * Neither the name of Google Inc. nor the names of its -contributors may be used to endorse or promote products derived from -this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -# The forked go-yaml.v3 library under this project is covered by two -different licenses (MIT and Apache): - -#### MIT License #### - -The following files were ported to Go from C files of libyaml, and thus -are still covered by their original MIT license, with the additional -copyright staring in 2011 when the project was ported over: - - apic.go emitterc.go parserc.go readerc.go scannerc.go - writerc.go yamlh.go yamlprivateh.go - -Copyright (c) 2006-2010 Kirill Simonov -Copyright (c) 2006-2011 Kirill Simonov - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies -of the Software, and to permit persons to whom the Software is furnished to do -so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - -### Apache License ### - -All the remaining project files are covered by the Apache license: - -Copyright (c) 2011-2019 Canonical Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. - -# The forked go-yaml.v2 library under the project is covered by an -Apache license: - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "{}" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright {yyyy} {name of copyright owner} - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - - diff --git a/cmd/manager/main.go b/cmd/manager/main.go index fc4bbde7042..8e1e6e9a712 100644 --- a/cmd/manager/main.go +++ b/cmd/manager/main.go @@ -27,6 +27,7 @@ import ( corev1 "k8s.io/api/core/v1" policyv1 "k8s.io/api/policy/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/selection" @@ -44,6 +45,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/metrics/filters" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" crwebhook "sigs.k8s.io/controller-runtime/pkg/webhook" + "sigs.k8s.io/yaml" "github.com/elastic/cloud-on-k8s/v3/pkg/about" agentv1alpha1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/agent/v1alpha1" @@ -69,6 +71,7 @@ import ( "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/container" commonesclient "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/esclient" commonlicense "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/license" + "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/nsmatch" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/operator" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/password" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/reconciler" @@ -86,6 +89,7 @@ import ( licensetrial "github.com/elastic/cloud-on-k8s/v3/pkg/controller/license/trial" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/logstash" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/maps" + "github.com/elastic/cloud-on-k8s/v3/pkg/controller/namespace" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/packageregistry" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/remotecluster" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/stackconfigpolicy" @@ -593,7 +597,29 @@ func startOperator(ctx context.Context) error { log.Error(err, "Failed to parse managed namespaces flag") return err } + + // Parse the optional namespace label selector. This field is config-file + // only (a metav1.LabelSelector object); it has no CLI flag. When set, + // the operator runs in dynamic mode: the cache is cluster-wide and a + // Matcher consulted by a predicate on every controller's watches + // evaluates the selector against Namespace labels per event. + parsedNsSelector, err := parseNSSelector(log) + if err != nil { + return err + } + dynamicNSSelector := parsedNsSelector != nil + + // Validate that dynamic ns selector cannot co-exist with the fixed list of managed namespaces. + if dynamicNSSelector && len(managedNamespaces) > 0 { + log.Error(errors.New("namespaces and namespaceSelector are mutually exclusive"), "Invalid configuration") + return errors.New("namespaces and namespaceSelector are mutually exclusive") + } + switch { + case dynamicNSSelector: + log.Info("Operator configured to manage namespaces dynamically via selector", + "selector", parsedNsSelector.String(), + "operator_namespace", operatorNamespace) case len(managedNamespaces) == 0: log.Info("Operator configured to manage all namespaces") case len(managedNamespaces) == 1 && managedNamespaces[0] == operatorNamespace: @@ -611,13 +637,26 @@ func startOperator(ctx context.Context) error { return err } + namespaceMatcher := nsmatch.NewNamespaceMatcher(parsedNsSelector, operatorNamespace) + opts.Cache = cache.Options{ DefaultNamespaces: map[string]cache.Config{}, DefaultTransform: cache.TransformStripManagedFields(), ByObject: byObject, } - for _, ns := range managedNamespaces { - opts.Cache.DefaultNamespaces[ns] = cache.Config{} + if !dynamicNSSelector { + for _, ns := range managedNamespaces { + opts.Cache.DefaultNamespaces[ns] = cache.Config{} + } + } else { + opts.NewClient = func(config *rest.Config, options client.Options) (client.Client, error) { + delegate, err := client.New(config, options) // cache-backed; options.Cache already set by mgr + if err != nil { + return nil, err + } + + return nsmatch.NewFilterClient(delegate, namespaceMatcher), nil + } } // only expose prometheus metrics if provided a non-zero port @@ -651,6 +690,11 @@ func startOperator(ctx context.Context) error { return err } + // The match-state map is maintained on every replica, but the subscribed + // controllers only consume broadcasts on the leader: give the matcher the + // election signal so Broadcast is a no-op until this replica is elected. + namespaceMatcher.SetElected(mgr.Elected()) + // Retrieve globally shared CA if any ca, err := readOptionalCA(viper.GetString(operator.CADirFlag)) if err != nil { @@ -752,6 +796,7 @@ func startOperator(ctx context.Context) error { SetDefaultSecurityContext: setDefaultSecurityContext, ValidateStorageClass: viper.GetBool(operator.ValidateStorageClassFlag), Tracer: tracer, + NamespaceMatcher: namespaceMatcher, } if viper.GetBool(operator.EnableWebhookFlag) { @@ -773,7 +818,7 @@ func startOperator(ctx context.Context) error { disableTelemetry := viper.GetBool(operator.DisableTelemetryFlag) telemetryInterval := viper.GetDuration(operator.TelemetryIntervalFlag) - go asyncTasks(ctx, mgr, cfg, managedNamespaces, operatorNamespace, operatorInfo, disableTelemetry, telemetryInterval, tracer, dialer) + go asyncTasks(ctx, mgr, cfg, managedNamespaces, operatorNamespace, namespaceMatcher, operatorInfo, disableTelemetry, telemetryInterval, tracer, dialer) log.Info("Starting the manager", "uuid", operatorInfo.OperatorUUID, "namespace", operatorNamespace, "version", operatorInfo.BuildInfo.Version, @@ -828,6 +873,7 @@ func asyncTasks( cfg *rest.Config, managedNamespaces []string, operatorNamespace string, + namespaceMatcher *nsmatch.NamespaceMatcher, operatorInfo about.OperatorInfo, disableTelemetry bool, telemetryInterval time.Duration, @@ -841,6 +887,9 @@ func asyncTasks( time.Sleep(10 * time.Second) // wait some arbitrary time for the manager to start mgr.GetCache().WaitForCacheSync(ctx) // wait until k8s client cache is initialized + if namespaceMatcher.SelectorEnabled() { + _ = namespaceMatcher.WaitForInitialSeeding(ctx) // wait for initial seeding to be done + } // Start the resource reporter go func() { @@ -851,16 +900,20 @@ func asyncTasks( if !disableTelemetry { // Start the telemetry reporter go func() { - tr := telemetry.NewReporter(operatorInfo, mgr.GetClient(), operatorNamespace, managedNamespaces, telemetryInterval, tracer) + tr := telemetry.NewReporter(operatorInfo, mgr.GetClient(), operatorNamespace, managedNamespaces, namespaceMatcher, telemetryInterval, tracer) tr.Start(ctx) }() } + namespaces := managedNamespaces + if namespaceMatcher.SelectorEnabled() { + namespaces = namespaceMatcher.MatchingNamespaces() + } // Garbage collect orphaned secrets leftover from deleted resources while the operator was not running // - association user secrets gcCtx := tracing.NewContextTransaction(ctx, tracer, tracing.RunOnceTxType, "garbage-collection", nil) gcCtx = logconf.AddToContext(gcCtx, logf.Log.WithName("garbage-collection")) - err := garbageCollectUsers(gcCtx, cfg, managedNamespaces) + err := garbageCollectUsers(gcCtx, cfg, namespaces) if err != nil { log.Error(err, "exiting due to unrecoverable error") os.Exit(1) @@ -927,6 +980,7 @@ func registerControllers(mgr manager.Manager, params operator.Parameters, access name string registerFunc func(manager.Manager, operator.Parameters) error }{ + {name: "Namespace", registerFunc: namespace.Add}, {name: "APMServer", registerFunc: apmserver.Add}, {name: "Elasticsearch", registerFunc: elasticsearch.Add}, {name: "ElasticsearchAutoscaling", registerFunc: autoscaling.Add}, @@ -1101,3 +1155,26 @@ func buildByObject(restrictWatchedResources bool) (map[client.Object]cache.ByObj return byObject, nil } + +func parseNSSelector(log logr.Logger) (labels.Selector, error) { + if !viper.IsSet(operator.NamespaceSelectorFlag) { + return nil, nil + } + raw := viper.Get(operator.NamespaceSelectorFlag) + yamlBytes, err := yaml.Marshal(raw) + if err != nil { + log.Error(err, "Failed to serialize namespace-selector for parsing") + return nil, err + } + var ls metav1.LabelSelector + if err := yaml.Unmarshal(yamlBytes, &ls); err != nil { + log.Error(err, "Failed to parse namespace-selector") + return nil, err + } + sel, err := metav1.LabelSelectorAsSelector(&ls) + if err != nil { + log.Error(err, "Invalid namespace-selector") + return nil, err + } + return sel, nil +} diff --git a/cmd/manager/validation.go b/cmd/manager/validation.go index 0687dc414b8..8b311696707 100644 --- a/cmd/manager/validation.go +++ b/cmd/manager/validation.go @@ -34,6 +34,7 @@ import ( esavalidation "github.com/elastic/cloud-on-k8s/v3/pkg/controller/autoscaling/elasticsearch/validation" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/certificates" commonlicense "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/license" + "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/nsmatch" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/operator" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/tracing" commonwebhook "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/webhook" @@ -79,34 +80,35 @@ func setupWebhook( ) { manageWebhookCerts := viper.GetBool(operator.ManageWebhookCertsFlag) if manageWebhookCerts { - if err := reconcileWebhookCertsAndAddController(ctx, mgr, params.CertRotation, clientset, tracer); err != nil { + if err := reconcileWebhookCertsAndAddController(ctx, mgr, params.CertRotation, params.NamespaceMatcher, clientset, tracer); err != nil { log.Error(err, "unable to setup the webhook certificates") os.Exit(1) } } checker := commonlicense.NewLicenseChecker(mgr.GetClient(), params.OperatorNamespace) + matcher := params.NamespaceMatcher // setup webhooks for supported types - commonwebhook.RegisterResourceWebhook(mgr, apmv1.WebhookPath, checker, managedNamespaces, apmv1.Validate, "APM Server") - commonwebhook.RegisterResourceWebhook(mgr, apmv1beta1.WebhookPath, checker, managedNamespaces, apmv1beta1.Validate, "APM Server") - commonwebhook.RegisterResourceWebhook(mgr, beatv1beta1.WebhookPath, checker, managedNamespaces, beatv1beta1.Validate, "Beat") - commonwebhook.RegisterResourceWebhook(mgr, entv1.WebhookPath, checker, managedNamespaces, entv1.Validate, "Enterprise Search") - commonwebhook.RegisterResourceWebhook(mgr, entv1beta1.WebhookPath, checker, managedNamespaces, entv1beta1.Validate, "Enterprise Search") - commonwebhook.RegisterResourceWebhook(mgr, esv1beta1.WebhookPath, checker, managedNamespaces, esv1beta1.Validate, "Elasticsearch") - commonwebhook.RegisterResourceWebhook(mgr, kbv1.WebhookPath, checker, managedNamespaces, kbv1.Validate, "Kibana") - commonwebhook.RegisterResourceWebhook(mgr, kbv1beta1.WebhookPath, checker, managedNamespaces, kbv1beta1.Validate, "Kibana") - commonwebhook.RegisterResourceWebhook(mgr, emsv1alpha1.WebhookPath, checker, managedNamespaces, emsv1alpha1.Validate, "Elastic Maps Server") - commonwebhook.RegisterResourceWebhook(mgr, eprv1alpha1.WebhookPath, checker, managedNamespaces, eprv1alpha1.Validate, "Package Registry") - scpvalidation.RegisterWebhook(mgr, checker, managedNamespaces, params.OperatorNamespace) + commonwebhook.RegisterResourceWebhook(mgr, apmv1.WebhookPath, checker, managedNamespaces, matcher, apmv1.Validate, "APM Server") + commonwebhook.RegisterResourceWebhook(mgr, apmv1beta1.WebhookPath, checker, managedNamespaces, matcher, apmv1beta1.Validate, "APM Server") + commonwebhook.RegisterResourceWebhook(mgr, beatv1beta1.WebhookPath, checker, managedNamespaces, matcher, beatv1beta1.Validate, "Beat") + commonwebhook.RegisterResourceWebhook(mgr, entv1.WebhookPath, checker, managedNamespaces, matcher, entv1.Validate, "Enterprise Search") + commonwebhook.RegisterResourceWebhook(mgr, entv1beta1.WebhookPath, checker, managedNamespaces, matcher, entv1beta1.Validate, "Enterprise Search") + commonwebhook.RegisterResourceWebhook(mgr, esv1beta1.WebhookPath, checker, managedNamespaces, matcher, esv1beta1.Validate, "Elasticsearch") + commonwebhook.RegisterResourceWebhook(mgr, kbv1.WebhookPath, checker, managedNamespaces, matcher, kbv1.Validate, "Kibana") + commonwebhook.RegisterResourceWebhook(mgr, kbv1beta1.WebhookPath, checker, managedNamespaces, matcher, kbv1beta1.Validate, "Kibana") + commonwebhook.RegisterResourceWebhook(mgr, emsv1alpha1.WebhookPath, checker, managedNamespaces, matcher, emsv1alpha1.Validate, "Elastic Maps Server") + commonwebhook.RegisterResourceWebhook(mgr, eprv1alpha1.WebhookPath, checker, managedNamespaces, matcher, eprv1alpha1.Validate, "Package Registry") + scpvalidation.RegisterWebhook(mgr, checker, managedNamespaces, params.OperatorNamespace, matcher) // Logstash, Elasticsearch v1, ElasticsearchAutoscaling, and AutoOps validating webhooks are wired up // separately so their validators can use the API client and/or embed license checks. Elasticsearch // v1beta1 remains in the RegisterResourceWebhook list above because it only needs a ValidateFunc. - esvalidation.RegisterWebhook(mgr, params.ValidateStorageClass, exposedNodeLabels, checker, managedNamespaces) - esavalidation.RegisterWebhook(mgr, params.ValidateStorageClass, checker, managedNamespaces) - lsvalidation.RegisterWebhook(mgr, params.ValidateStorageClass, managedNamespaces) - autoopsvalidation.RegisterWebhook(mgr, checker, managedNamespaces) - agentcontroller.RegisterWebhook(mgr, checker, managedNamespaces) + esvalidation.RegisterWebhook(mgr, params.ValidateStorageClass, exposedNodeLabels, checker, managedNamespaces, matcher) + esavalidation.RegisterWebhook(mgr, params.ValidateStorageClass, checker, managedNamespaces, matcher) + lsvalidation.RegisterWebhook(mgr, params.ValidateStorageClass, managedNamespaces, matcher) + autoopsvalidation.RegisterWebhook(mgr, checker, managedNamespaces, matcher) + agentcontroller.RegisterWebhook(mgr, checker, managedNamespaces, matcher) // wait for the secret to be populated in the local filesystem before returning interval := time.Second * 1 @@ -134,7 +136,7 @@ func setupWebhook( } } -func reconcileWebhookCertsAndAddController(ctx context.Context, mgr manager.Manager, certRotation certificates.RotationParams, clientset kubernetes.Interface, tracer *apm.Tracer) error { +func reconcileWebhookCertsAndAddController(ctx context.Context, mgr manager.Manager, certRotation certificates.RotationParams, m *nsmatch.NamespaceMatcher, clientset kubernetes.Interface, tracer *apm.Tracer) error { ctx = tracing.NewContextTransaction(ctx, tracer, tracing.ReconciliationTxType, webhook.ControllerName, nil) defer tracing.EndContextTransaction(ctx) log.Info("Automatic management of the webhook certificates enabled") @@ -157,5 +159,5 @@ func reconcileWebhookCertsAndAddController(ctx context.Context, mgr manager.Mana return err } - return webhook.Add(mgr, webhookParams, clientset, wh, tracer) + return webhook.Add(mgr, m, webhookParams, clientset, wh, tracer) } diff --git a/deploy/eck-operator/templates/configmap.yaml b/deploy/eck-operator/templates/configmap.yaml index 995f523c45b..50e965474e0 100644 --- a/deploy/eck-operator/templates/configmap.yaml +++ b/deploy/eck-operator/templates/configmap.yaml @@ -71,6 +71,10 @@ data: {{- with .Values.managedNamespaces }} namespaces: [{{ join "," . }}] {{- end }} + {{- with .Values.namespaceSelector }} + namespace-selector: + {{- . | toYaml | nindent 6 }} + {{- end }} operator-namespace: {{ .Release.Namespace }} enable-leader-election: {{ .Values.config.enableLeaderElection }} elasticsearch-observation-interval: {{ .Values.config.elasticsearchObservationInterval }} diff --git a/deploy/eck-operator/templates/validate-chart.yaml b/deploy/eck-operator/templates/validate-chart.yaml index 326b70bcdc6..2657672898c 100644 --- a/deploy/eck-operator/templates/validate-chart.yaml +++ b/deploy/eck-operator/templates/validate-chart.yaml @@ -27,3 +27,13 @@ {{- fail "Leader election must be enabled with more than one replica" -}} {{- end -}} {{- end -}} + +{{- if not (empty .Values.namespaceSelector) -}} + {{- if not (empty .Values.managedNamespaces) -}} + {{- fail "namespaceSelector and managedNamespaces are mutually exclusive" -}} + {{- end -}} + + {{- if not .Values.createClusterScopedResources -}} + {{- fail "namespaceSelector requires createClusterScopedResources to be true" -}} + {{- end -}} +{{- end -}} diff --git a/deploy/eck-operator/values.yaml b/deploy/eck-operator/values.yaml index b5824aa9176..a82c1456958 100644 --- a/deploy/eck-operator/values.yaml +++ b/deploy/eck-operator/values.yaml @@ -5,8 +5,25 @@ nameOverride: "elastic-operator" fullnameOverride: "elastic-operator" # managedNamespaces is the set of namespaces that the operator manages. Leave empty to manage all namespaces. +# Mutually exclusive with namespaceSelector. managedNamespaces: [] +# namespaceSelector is a Kubernetes label selector that dynamically controls which namespaces the operator manages. +# The operator watches all namespaces cluster-wide and filters events by matching each namespace's labels against +# this selector at runtime. When a namespace gains or loses the matching labels the operator automatically +# starts or stops managing resources in that namespace without a restart. +# Mutually exclusive with managedNamespaces. Requires createClusterScopedResources: true. +# Example: +# namespaceSelector: +# matchLabels: +# eck-managed: "true" +# namespaceSelector: +# matchExpressions: +# - key: environment +# operator: In +# values: [production, staging] +namespaceSelector: {} + # installCRDs determines whether Custom Resource Definitions (CRD) are installed by the chart. # Note that CRDs are global resources and require cluster admin privileges to install. # If you are sharing a cluster with other users who may want to install ECK on their own namespaces, setting this to true can have unintended consequences. diff --git a/docs/reference/third-party-dependencies/main.md b/docs/reference/third-party-dependencies/main.md index 0feaa1e5359..12b98cd9838 100644 --- a/docs/reference/third-party-dependencies/main.md +++ b/docs/reference/third-party-dependencies/main.md @@ -66,6 +66,7 @@ This page lists the third-party dependencies used to build {{eck}} from the main | [k8s.io/utils](https://github.com/kubernetes/utils) | v0.0.0-20260319190234-28399d86e0b5 | Apache-2.0 | | [sigs.k8s.io/controller-runtime](https://sigs.k8s.io/controller-runtime) | v0.24.1 | Apache-2.0 | | [sigs.k8s.io/controller-tools](https://sigs.k8s.io/controller-tools) | v0.21.0 | Apache-2.0 | +| [sigs.k8s.io/yaml](https://sigs.k8s.io/yaml) | v1.6.0 | Apache-2.0 | ## Indirect dependencies [k8s-dependencies-indirect] @@ -274,5 +275,4 @@ This page lists the third-party dependencies used to build {{eck}} from the main | [sigs.k8s.io/json](https://sigs.k8s.io/json) | v0.0.0-20250730193827-2d320260d730 | Apache-2.0 | | [sigs.k8s.io/randfill](https://sigs.k8s.io/randfill) | v1.0.0 | Apache-2.0 | | [sigs.k8s.io/structured-merge-diff/v6](https://sigs.k8s.io/structured-merge-diff/v6) | v6.4.0 | Apache-2.0 | -| [sigs.k8s.io/yaml](https://sigs.k8s.io/yaml) | v1.6.0 | Apache-2.0 | diff --git a/go.mod b/go.mod index 4aac094e05e..507fb9efdef 100644 --- a/go.mod +++ b/go.mod @@ -55,6 +55,7 @@ require ( k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 sigs.k8s.io/controller-runtime v0.24.1 sigs.k8s.io/controller-tools v0.21.0 + sigs.k8s.io/yaml v1.6.0 ) require ( @@ -170,6 +171,7 @@ require ( github.com/spf13/cast v1.10.0 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect github.com/stoewer/go-strcase v1.3.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/tidwall/gjson v1.19.0 // indirect github.com/tidwall/match v1.1.1 // indirect @@ -223,7 +225,6 @@ require ( sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.4.0 // indirect - sigs.k8s.io/yaml v1.6.0 // indirect ) // both of these dependencies are used by vegeta, but the version they use is older and did not include a licence. we require the licence and so pin both of these diff --git a/pkg/controller/agent/controller.go b/pkg/controller/agent/controller.go index b140e2f5899..58ce1a0660f 100644 --- a/pkg/controller/agent/controller.go +++ b/pkg/controller/agent/controller.go @@ -14,11 +14,11 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/types" toolsevents "k8s.io/client-go/tools/events" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/controller-runtime/pkg/reconcile" - "sigs.k8s.io/controller-runtime/pkg/source" agentv1alpha1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/agent/v1alpha1" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/association" @@ -64,15 +64,16 @@ func newReconciler(mgr manager.Manager, params operator.Parameters) *ReconcileAg // addWatches adds watches for all resources this controller cares about func addWatches(mgr manager.Manager, c controller.Controller, r *ReconcileAgent) error { + m := r.NamespaceMatcher // Watch for changes to Agent if err := c.Watch( - source.Kind(mgr.GetCache(), &agentv1alpha1.Agent{}, &handler.TypedEnqueueRequestForObject[*agentv1alpha1.Agent]{})); err != nil { + watches.NamespacedKind(m, mgr.GetCache(), &agentv1alpha1.Agent{}, &handler.TypedEnqueueRequestForObject[*agentv1alpha1.Agent]{})); err != nil { return err } // Watch DaemonSets if err := c.Watch( - source.Kind(mgr.GetCache(), &appsv1.DaemonSet{}, + watches.NamespacedKind(m, mgr.GetCache(), &appsv1.DaemonSet{}, handler.TypedEnqueueRequestForOwner[*appsv1.DaemonSet](mgr.GetScheme(), mgr.GetRESTMapper(), &agentv1alpha1.Agent{}, handler.OnlyControllerOwner()), )); err != nil { @@ -81,7 +82,7 @@ func addWatches(mgr manager.Manager, c controller.Controller, r *ReconcileAgent) // Watch Deployments if err := c.Watch( - source.Kind(mgr.GetCache(), &appsv1.Deployment{}, + watches.NamespacedKind(m, mgr.GetCache(), &appsv1.Deployment{}, handler.TypedEnqueueRequestForOwner[*appsv1.Deployment](mgr.GetScheme(), mgr.GetRESTMapper(), &agentv1alpha1.Agent{}, handler.OnlyControllerOwner()), )); err != nil { @@ -90,7 +91,7 @@ func addWatches(mgr manager.Manager, c controller.Controller, r *ReconcileAgent) // Watch StatefulSets if err := c.Watch( - source.Kind(mgr.GetCache(), &appsv1.StatefulSet{}, + watches.NamespacedKind(m, mgr.GetCache(), &appsv1.StatefulSet{}, handler.TypedEnqueueRequestForOwner[*appsv1.StatefulSet](mgr.GetScheme(), mgr.GetRESTMapper(), &agentv1alpha1.Agent{}, handler.OnlyControllerOwner()), )); err != nil { @@ -99,13 +100,13 @@ func addWatches(mgr manager.Manager, c controller.Controller, r *ReconcileAgent) // Watch Pods, to ensure `status.version` is correctly reconciled on any change. // Watching Deployments or DaemonSets only may lead to missing some events. - if err := watches.WatchPods(mgr, c, NameLabelName); err != nil { + if err := watches.WatchPods(mgr, c, m, NameLabelName); err != nil { return err } // Watch Secrets if err := c.Watch( - source.Kind(mgr.GetCache(), &corev1.Secret{}, + watches.NamespacedKind(m, mgr.GetCache(), &corev1.Secret{}, handler.TypedEnqueueRequestForOwner[*corev1.Secret](mgr.GetScheme(), mgr.GetRESTMapper(), &agentv1alpha1.Agent{}, handler.OnlyControllerOwner()), )); err != nil { @@ -115,7 +116,7 @@ func addWatches(mgr manager.Manager, c controller.Controller, r *ReconcileAgent) // Watch services - Agent in Fleet mode with Fleet Server enabled configures and exposes a Service // for Elastic Agents to connect to. if err := c.Watch( - source.Kind(mgr.GetCache(), &corev1.Service{}, + watches.NamespacedKind(m, mgr.GetCache(), &corev1.Service{}, handler.TypedEnqueueRequestForOwner[*corev1.Service](mgr.GetScheme(), mgr.GetRESTMapper(), &agentv1alpha1.Agent{}, handler.OnlyControllerOwner()), )); err != nil { @@ -123,15 +124,17 @@ func addWatches(mgr manager.Manager, c controller.Controller, r *ReconcileAgent) } // Watch soft-owned secrets (e.g. client certificate secrets for Fleet Server mTLS) - if err := watches.WatchSoftOwnedSecrets(mgr, c, agentv1alpha1.Kind); err != nil { + if err := watches.WatchSoftOwnedSecrets(mgr, c, r.NamespaceMatcher, agentv1alpha1.Kind); err != nil { return err } // Watch dynamically referenced Secrets - return c.Watch( - source.Kind(mgr.GetCache(), &corev1.Secret{}, - r.dynamicWatches.Secrets, - )) + if err := c.Watch(watches.NamespacedKind(m, mgr.GetCache(), &corev1.Secret{}, r.dynamicWatches.Secrets)); err != nil { + return err + } + return watches.WatchNamespaceFlips(c, mgr.GetCache(), r.NamespaceMatcher, func() client.ObjectList { + return &agentv1alpha1.AgentList{} + }) } var _ reconcile.Reconciler = (*ReconcileAgent)(nil) @@ -150,6 +153,10 @@ type ReconcileAgent struct { // Reconcile reads that state of the cluster for an Agent object and makes changes based on the state read // and what is in the Agent.Spec func (r *ReconcileAgent) Reconcile(ctx context.Context, request reconcile.Request) (reconcile.Result, error) { + if !r.NamespaceMatcher.Matches(request.Namespace) { + r.onNamespaceOutOfScope(request.NamespacedName) + return reconcile.Result{}, nil + } ctx = common.NewReconciliationContext(ctx, &r.iteration, r.Tracer, controllerName, "agent_name", request) defer common.LogReconciliationRun(logconf.FromContext(ctx))() defer tracing.EndContextTransaction(ctx) @@ -242,8 +249,12 @@ func (r *ReconcileAgent) validate(ctx context.Context, agent agentv1alpha1.Agent return nil } -func (r *ReconcileAgent) onDelete(ctx context.Context, obj types.NamespacedName) error { +func (r *ReconcileAgent) onNamespaceOutOfScope(obj types.NamespacedName) { r.dynamicWatches.Secrets.RemoveHandlerForKey(keystore.SecureSettingsWatchName(obj)) r.dynamicWatches.Secrets.RemoveHandlerForKey(common.ConfigRefWatchName(obj)) +} + +func (r *ReconcileAgent) onDelete(ctx context.Context, obj types.NamespacedName) error { + r.onNamespaceOutOfScope(obj) return reconciler.GarbageCollectSoftOwnedSecrets(ctx, r.Client, obj, agentv1alpha1.Kind) } diff --git a/pkg/controller/agent/webhook.go b/pkg/controller/agent/webhook.go index af150e34137..62979b6f2ac 100644 --- a/pkg/controller/agent/webhook.go +++ b/pkg/controller/agent/webhook.go @@ -15,14 +15,15 @@ import ( agentv1alpha1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/agent/v1alpha1" commonlicense "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/license" + "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/nsmatch" commonwebhook "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/webhook" ulog "github.com/elastic/cloud-on-k8s/v3/pkg/utils/log" ) // RegisterWebhook registers the Agent validating webhook with license-aware validation. -func RegisterWebhook(mgr ctrl.Manager, checker commonlicense.Checker, managedNamespaces []string) { +func RegisterWebhook(mgr ctrl.Manager, checker commonlicense.Checker, managedNamespaces []string, matcher *nsmatch.NamespaceMatcher) { inner := &webhookValidator{licenseChecker: checker} - v := commonwebhook.NewResourceValidator[*agentv1alpha1.Agent](checker, managedNamespaces, inner) + v := commonwebhook.NewResourceValidator[*agentv1alpha1.Agent](checker, managedNamespaces, inner).WithNamespaceMatcher(matcher) wh := admission.WithValidator[*agentv1alpha1.Agent](mgr.GetScheme(), v) mgr.GetWebhookServer().Register(agentv1alpha1.WebhookPath, wh) ulog.Log.Info("Registering Agent validating webhook", "path", agentv1alpha1.WebhookPath) diff --git a/pkg/controller/apmserver/controller.go b/pkg/controller/apmserver/controller.go index f3eeec25d7a..085f0b0b8c9 100644 --- a/pkg/controller/apmserver/controller.go +++ b/pkg/controller/apmserver/controller.go @@ -19,11 +19,11 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" toolsevents "k8s.io/client-go/tools/events" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/controller-runtime/pkg/reconcile" - "sigs.k8s.io/controller-runtime/pkg/source" apmv1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/apm/v1" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/association" @@ -105,14 +105,15 @@ func newReconciler(mgr manager.Manager, params operator.Parameters) *ReconcileAp } func addWatches(mgr manager.Manager, c controller.Controller, r *ReconcileApmServer) error { + m := r.NamespaceMatcher // Watch for changes to ApmServer - err := c.Watch(source.Kind(mgr.GetCache(), &apmv1.ApmServer{}, &handler.TypedEnqueueRequestForObject[*apmv1.ApmServer]{})) + err := c.Watch(watches.NamespacedKind(m, mgr.GetCache(), &apmv1.ApmServer{}, &handler.TypedEnqueueRequestForObject[*apmv1.ApmServer]{})) if err != nil { return err } // Watch Deployments - if err := c.Watch(source.Kind(mgr.GetCache(), &appsv1.Deployment{}, handler.TypedEnqueueRequestForOwner[*appsv1.Deployment]( + if err := c.Watch(watches.NamespacedKind(m, mgr.GetCache(), &appsv1.Deployment{}, handler.TypedEnqueueRequestForOwner[*appsv1.Deployment]( mgr.GetScheme(), mgr.GetRESTMapper(), &apmv1.ApmServer{}, handler.OnlyControllerOwner(), ))); err != nil { @@ -121,12 +122,12 @@ func addWatches(mgr manager.Manager, c controller.Controller, r *ReconcileApmSer // Watch Pods, to ensure `status.version` and version upgrades are correctly reconciled on any change. // Watching Deployments only may lead to missing some events. - if err := watches.WatchPods(mgr, c, ApmServerNameLabelName); err != nil { + if err := watches.WatchPods(mgr, c, m, ApmServerNameLabelName); err != nil { return err } // Watch services - if err := c.Watch(source.Kind(mgr.GetCache(), &corev1.Service{}, handler.TypedEnqueueRequestForOwner[*corev1.Service]( + if err := c.Watch(watches.NamespacedKind(m, mgr.GetCache(), &corev1.Service{}, handler.TypedEnqueueRequestForOwner[*corev1.Service]( mgr.GetScheme(), mgr.GetRESTMapper(), &apmv1.ApmServer{}, handler.OnlyControllerOwner(), ))); err != nil { @@ -134,18 +135,23 @@ func addWatches(mgr manager.Manager, c controller.Controller, r *ReconcileApmSer } // Watch owned and soft-owned secrets - if err := c.Watch(source.Kind(mgr.GetCache(), &corev1.Secret{}, handler.TypedEnqueueRequestForOwner[*corev1.Secret]( + if err := c.Watch(watches.NamespacedKind(m, mgr.GetCache(), &corev1.Secret{}, handler.TypedEnqueueRequestForOwner[*corev1.Secret]( mgr.GetScheme(), mgr.GetRESTMapper(), &apmv1.ApmServer{}, handler.OnlyControllerOwner(), ))); err != nil { return err } - if err := watches.WatchSoftOwnedSecrets(mgr, c, apmv1.Kind); err != nil { + if err := watches.WatchSoftOwnedSecrets(mgr, c, m, apmv1.Kind); err != nil { return err } // dynamically watch referenced secrets to connect to Elasticsearch - return c.Watch(source.Kind(mgr.GetCache(), &corev1.Secret{}, r.dynamicWatches.Secrets)) + if err := c.Watch(watches.NamespacedKind(m, mgr.GetCache(), &corev1.Secret{}, r.dynamicWatches.Secrets)); err != nil { + return err + } + return watches.WatchNamespaceFlips(c, mgr.GetCache(), r.NamespaceMatcher, func() client.ObjectList { + return &apmv1.ApmServerList{} + }) } var _ reconcile.Reconciler = (*ReconcileApmServer)(nil) @@ -181,6 +187,10 @@ var _ driver.Interface = (*ReconcileApmServer)(nil) // Reconcile reads that state of the cluster for a ApmServer object and makes changes based on the state read // and what is in the ApmServer.Spec func (r *ReconcileApmServer) Reconcile(ctx context.Context, request reconcile.Request) (reconcile.Result, error) { + if !r.NamespaceMatcher.Matches(request.Namespace) { + r.onNamespaceOutOfScope(request.NamespacedName) + return reconcile.Result{}, nil + } ctx = common.NewReconciliationContext(ctx, &r.iteration, r.Tracer, controllerName, "as_name", request) defer common.LogReconciliationRun(ulog.FromContext(ctx))() defer tracing.EndContextTransaction(ctx) @@ -307,11 +317,13 @@ func (r *ReconcileApmServer) validate(ctx context.Context, as *apmv1.ApmServer) return nil } -func (r *ReconcileApmServer) onDelete(ctx context.Context, obj types.NamespacedName) error { - // Clean up watches set on secure settings +func (r *ReconcileApmServer) onNamespaceOutOfScope(obj types.NamespacedName) { r.dynamicWatches.Secrets.RemoveHandlerForKey(keystore.SecureSettingsWatchName(obj)) - // Clean up watches set on custom http tls certificates r.dynamicWatches.Secrets.RemoveHandlerForKey(certificates.CertificateWatchKey(Namer, obj.Name)) +} + +func (r *ReconcileApmServer) onDelete(ctx context.Context, obj types.NamespacedName) error { + r.onNamespaceOutOfScope(obj) return reconciler.GarbageCollectSoftOwnedSecrets(ctx, r.Client, obj, apmv1.Kind) } diff --git a/pkg/controller/association/controller.go b/pkg/controller/association/controller.go index 1425d301fc8..ac889fd60e7 100644 --- a/pkg/controller/association/controller.go +++ b/pkg/controller/association/controller.go @@ -5,19 +5,23 @@ package association import ( + "context" "fmt" corev1 "k8s.io/api/core/v1" + apimeta "k8s.io/apimachinery/pkg/api/meta" + "sigs.k8s.io/controller-runtime/pkg/cache" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/manager" - "sigs.k8s.io/controller-runtime/pkg/source" + "sigs.k8s.io/controller-runtime/pkg/reconcile" commonv1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/common/v1" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/operator" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/watches" + "github.com/elastic/cloud-on-k8s/v3/pkg/utils/k8s" "github.com/elastic/cloud-on-k8s/v3/pkg/utils/rbac" ) @@ -53,13 +57,14 @@ func AddAssociationController( } func addWatches(mgr manager.Manager, c controller.Controller, r *Reconciler) error { + m := r.NamespaceMatcher // Watch the associated resource (e.g. Kibana for a Kibana -> Elasticsearch association) - if err := c.Watch(source.Kind(mgr.GetCache(), r.AssociatedObjTemplate(), &handler.TypedEnqueueRequestForObject[commonv1.Associated]{})); err != nil { + if err := c.Watch(watches.NamespacedKind(m, mgr.GetCache(), r.AssociatedObjTemplate(), &handler.TypedEnqueueRequestForObject[commonv1.Associated]{})); err != nil { return err } // Watch Secrets owned by the associated resource - if err := c.Watch(source.Kind(mgr.GetCache(), &corev1.Secret{}, handler.TypedEnqueueRequestForOwner[*corev1.Secret]( + if err := c.Watch(watches.NamespacedKind(m, mgr.GetCache(), &corev1.Secret{}, handler.TypedEnqueueRequestForOwner[*corev1.Secret]( mgr.GetScheme(), mgr.GetRESTMapper(), r.AssociatedObjTemplate(), handler.OnlyControllerOwner(), ))); err != nil { @@ -67,17 +72,76 @@ func addWatches(mgr manager.Manager, c controller.Controller, r *Reconciler) err } // Dynamically watch the referenced resources (e.g. Elasticsearch B for a Kibana A -> Elasticsearch B association) - if err := c.Watch(source.Kind(mgr.GetCache(), r.ReferencedObjTemplate(), r.watches.ReferencedResources)); err != nil { + if err := c.Watch(watches.NamespacedKind(m, mgr.GetCache(), r.ReferencedObjTemplate(), r.watches.ReferencedResources)); err != nil { return err } // Dynamically watch Secrets (CA Secret of the referenced resource, ES user secret or custom referenced object secret) - if err := c.Watch(source.Kind(mgr.GetCache(), &corev1.Secret{}, r.watches.Secrets)); err != nil { + if err := c.Watch(watches.NamespacedKind(m, mgr.GetCache(), &corev1.Secret{}, r.watches.Secrets)); err != nil { return err } // Dynamically watch Service objects for custom services setup by the user - return c.Watch(source.Kind(mgr.GetCache(), &corev1.Service{}, r.watches.Services)) + if err := c.Watch(watches.NamespacedKind(m, mgr.GetCache(), &corev1.Service{}, r.watches.Services)); err != nil { + return err + } + + return watches.WatchNamespaceFlipsMapped(c, r.NamespaceMatcher, namespaceFlipRequests(mgr.GetCache(), r)) +} + +// namespaceFlipRequests returns a mapper translating a namespace match-state change into +// reconcile requests for the associated resources affected by it: the ones living in the +// flipped namespace, and the ones living elsewhere whose association references a resource +// in the flipped namespace (e.g. a Kibana in an in-scope namespace referencing an +// Elasticsearch in the namespace that just went out of scope). The latter cannot rely on the +// dynamic referenced-resource watch: a namespace label change produces no event on the +// referenced object, and the NamespacedKind predicate drops events from de-scoped namespaces. +// +// Known limitation: transitive references (e.g. Agent -> Fleet Server -> Elasticsearch, each +// in a different namespace) are matched on the direct reference only; a flip of the transitive +// Elasticsearch namespace does not re-enqueue the Agent. +func namespaceFlipRequests(cache cache.Cache, r *Reconciler) func(context.Context, *corev1.Namespace) []reconcile.Request { + return func(ctx context.Context, ns *corev1.Namespace) []reconcile.Request { + list := r.AssociatedObjListTemplate() + // List **cluster-wide** from the cache (not the FilterClient): associated resources + // referencing the flipped namespace can live in any matched namespace, and + // resources in the namespace being de-scoped would be hidden by the FilterClient. + if err := cache.List(ctx, list); err != nil { + return nil + } + items, err := apimeta.ExtractList(list) + if err != nil { + return nil + } + var reqs []reconcile.Request + for _, item := range items { + associated, ok := item.(commonv1.Associated) + if !ok { + continue + } + + // case A: Reconcile the associated resources that their own namespace changed state. + if associated.GetNamespace() == ns.Name { + reqs = append(reqs, reconcile.Request{NamespacedName: k8s.ExtractNamespacedName(associated)}) + continue + } + + // case B: Reconcile the associated resources that at least one of their associations belong to the namespace that changed state. + for _, association := range associated.GetAssociations() { + // omit the ones that belong to another association controller + if association.AssociationType() != r.AssociationType { + continue + } + // AssociationRef() resolves an empty ref namespace to the associated + // resource's namespace, so the comparison is direct. + if association.AssociationRef().NamespacedName().Namespace == ns.Name { + reqs = append(reqs, reconcile.Request{NamespacedName: k8s.ExtractNamespacedName(associated)}) + break + } + } + } + return reqs + } } // referencedObjKind derives the Kind of the referenced resource from the manager's scheme. diff --git a/pkg/controller/association/controller/agent_es.go b/pkg/controller/association/controller/agent_es.go index 4d37470d2dc..33387872253 100644 --- a/pkg/controller/association/controller/agent_es.go +++ b/pkg/controller/association/controller/agent_es.go @@ -35,6 +35,7 @@ func AddAgentES(mgr manager.Manager, accessReviewer rbac.AccessReviewer, params return association.AddAssociationController(mgr, accessReviewer, params, association.AssociationInfo{ AssociationType: commonv1.ElasticsearchAssociationType, AssociatedObjTemplate: func() commonv1.Associated { return &agentv1alpha1.Agent{} }, + AssociatedObjListTemplate: func() client.ObjectList { return &agentv1alpha1.AgentList{} }, ReferencedObjTemplate: func() client.Object { return &esv1.Elasticsearch{} }, ReferencedResourceVersion: referencedElasticsearchStatusVersion, ExternalServiceURL: getElasticsearchExternalURL, diff --git a/pkg/controller/association/controller/agent_fleetserver.go b/pkg/controller/association/controller/agent_fleetserver.go index 616521af753..9dae029b5d3 100644 --- a/pkg/controller/association/controller/agent_fleetserver.go +++ b/pkg/controller/association/controller/agent_fleetserver.go @@ -34,6 +34,7 @@ import ( func AddAgentFleetServer(mgr manager.Manager, accessReviewer rbac.AccessReviewer, params operator.Parameters) error { return association.AddAssociationController(mgr, accessReviewer, params, association.AssociationInfo{ AssociatedObjTemplate: func() commonv1.Associated { return &agentv1alpha1.Agent{} }, + AssociatedObjListTemplate: func() client.ObjectList { return &agentv1alpha1.AgentList{} }, ReferencedObjTemplate: func() client.Object { return &agentv1alpha1.Agent{} }, ExternalServiceURL: getFleetServerExternalURL, ReferencedResourceVersion: referencedFleetServerStatusVersion, diff --git a/pkg/controller/association/controller/agent_kibana.go b/pkg/controller/association/controller/agent_kibana.go index 1e77155b5a0..3890b31525b 100644 --- a/pkg/controller/association/controller/agent_kibana.go +++ b/pkg/controller/association/controller/agent_kibana.go @@ -24,6 +24,7 @@ import ( func AddAgentKibana(mgr manager.Manager, accessReviewer rbac.AccessReviewer, params operator.Parameters) error { return association.AddAssociationController(mgr, accessReviewer, params, association.AssociationInfo{ AssociatedObjTemplate: func() commonv1.Associated { return &agentv1alpha1.Agent{} }, + AssociatedObjListTemplate: func() client.ObjectList { return &agentv1alpha1.AgentList{} }, ReferencedObjTemplate: func() client.Object { return &kbv1.Kibana{} }, ExternalServiceURL: getKibanaExternalURL, ReferencedResourceVersion: referencedKibanaStatusVersion, diff --git a/pkg/controller/association/controller/apm_es.go b/pkg/controller/association/controller/apm_es.go index 5fd2dca7e53..368c6891a62 100644 --- a/pkg/controller/association/controller/apm_es.go +++ b/pkg/controller/association/controller/apm_es.go @@ -40,6 +40,7 @@ func AddApmES(mgr manager.Manager, accessReviewer rbac.AccessReviewer, params op return association.AddAssociationController(mgr, accessReviewer, params, association.AssociationInfo{ AssociatedShortName: "apm", AssociatedObjTemplate: func() commonv1.Associated { return &apmv1.ApmServer{} }, + AssociatedObjListTemplate: func() client.ObjectList { return &apmv1.ApmServerList{} }, ReferencedObjTemplate: func() client.Object { return &esv1.Elasticsearch{} }, AssociationType: commonv1.ElasticsearchAssociationType, ReferencedResourceVersion: referencedElasticsearchStatusVersion, diff --git a/pkg/controller/association/controller/apm_kibana.go b/pkg/controller/association/controller/apm_kibana.go index 413a5f135cd..49e1f20d3a3 100644 --- a/pkg/controller/association/controller/apm_kibana.go +++ b/pkg/controller/association/controller/apm_kibana.go @@ -29,6 +29,7 @@ func AddApmKibana(mgr manager.Manager, accessReviewer rbac.AccessReviewer, param return association.AddAssociationController(mgr, accessReviewer, params, association.AssociationInfo{ AssociatedShortName: "apm", AssociatedObjTemplate: func() commonv1.Associated { return &apmv1.ApmServer{} }, + AssociatedObjListTemplate: func() client.ObjectList { return &apmv1.ApmServerList{} }, ReferencedObjTemplate: func() client.Object { return &kbv1.Kibana{} }, ExternalServiceURL: getKibanaExternalURL, ReferencedResourceVersion: referencedKibanaStatusVersion, diff --git a/pkg/controller/association/controller/beat_es.go b/pkg/controller/association/controller/beat_es.go index 5566fa81fe8..f58df28d05c 100644 --- a/pkg/controller/association/controller/beat_es.go +++ b/pkg/controller/association/controller/beat_es.go @@ -40,6 +40,7 @@ const ( func AddBeatES(mgr manager.Manager, accessReviewer rbac.AccessReviewer, params operator.Parameters) error { return association.AddAssociationController(mgr, accessReviewer, params, association.AssociationInfo{ AssociatedObjTemplate: func() commonv1.Associated { return &beatv1beta1.Beat{} }, + AssociatedObjListTemplate: func() client.ObjectList { return &beatv1beta1.BeatList{} }, ReferencedObjTemplate: func() client.Object { return &esv1.Elasticsearch{} }, AssociationType: commonv1.ElasticsearchAssociationType, ReferencedResourceVersion: referencedElasticsearchStatusVersion, diff --git a/pkg/controller/association/controller/beat_kibana.go b/pkg/controller/association/controller/beat_kibana.go index ad549fad77e..cda7a395ab5 100644 --- a/pkg/controller/association/controller/beat_kibana.go +++ b/pkg/controller/association/controller/beat_kibana.go @@ -27,6 +27,7 @@ import ( func AddBeatKibana(mgr manager.Manager, accessReviewer rbac.AccessReviewer, params operator.Parameters) error { return association.AddAssociationController(mgr, accessReviewer, params, association.AssociationInfo{ AssociatedObjTemplate: func() commonv1.Associated { return &beatv1beta1.Beat{} }, + AssociatedObjListTemplate: func() client.ObjectList { return &beatv1beta1.BeatList{} }, ReferencedObjTemplate: func() client.Object { return &kbv1.Kibana{} }, ExternalServiceURL: getKibanaExternalURL, ReferencedResourceVersion: referencedKibanaStatusVersion, diff --git a/pkg/controller/association/controller/beat_monitoring.go b/pkg/controller/association/controller/beat_monitoring.go index 95254b33a2f..996f7bb5159 100644 --- a/pkg/controller/association/controller/beat_monitoring.go +++ b/pkg/controller/association/controller/beat_monitoring.go @@ -25,6 +25,7 @@ import ( func AddBeatMonitoring(mgr manager.Manager, accessReviewer rbac.AccessReviewer, params operator.Parameters) error { return association.AddAssociationController(mgr, accessReviewer, params, association.AssociationInfo{ AssociatedObjTemplate: func() commonv1.Associated { return &beatv1b1.Beat{} }, + AssociatedObjListTemplate: func() client.ObjectList { return &beatv1b1.BeatList{} }, ReferencedObjTemplate: func() client.Object { return &esv1.Elasticsearch{} }, ReferencedResourceVersion: referencedElasticsearchStatusVersion, ExternalServiceURL: getElasticsearchExternalURL, diff --git a/pkg/controller/association/controller/ent_es.go b/pkg/controller/association/controller/ent_es.go index 9086eb301ab..3cf2f833c19 100644 --- a/pkg/controller/association/controller/ent_es.go +++ b/pkg/controller/association/controller/ent_es.go @@ -35,6 +35,7 @@ const ( func AddEntES(mgr manager.Manager, accessReviewer rbac.AccessReviewer, params operator.Parameters) error { return association.AddAssociationController(mgr, accessReviewer, params, association.AssociationInfo{ AssociatedObjTemplate: func() commonv1.Associated { return &entv1.EnterpriseSearch{} }, + AssociatedObjListTemplate: func() client.ObjectList { return &entv1.EnterpriseSearchList{} }, ReferencedObjTemplate: func() client.Object { return &esv1.Elasticsearch{} }, ReferencedResourceVersion: referencedElasticsearchStatusVersion, AssociationType: commonv1.ElasticsearchAssociationType, diff --git a/pkg/controller/association/controller/es_monitoring.go b/pkg/controller/association/controller/es_monitoring.go index cc38aae7b6e..3fddd265b8d 100644 --- a/pkg/controller/association/controller/es_monitoring.go +++ b/pkg/controller/association/controller/es_monitoring.go @@ -41,6 +41,7 @@ func AddEsMonitoring(mgr manager.Manager, accessReviewer rbac.AccessReviewer, pa func esMonitoringAssociationInfo() association.AssociationInfo { return association.AssociationInfo{ AssociatedObjTemplate: func() commonv1.Associated { return &esv1.Elasticsearch{} }, + AssociatedObjListTemplate: func() client.ObjectList { return &esv1.ElasticsearchList{} }, ReferencedObjTemplate: func() client.Object { return &esv1.Elasticsearch{} }, ReferencedResourceVersion: referencedElasticsearchStatusVersion, ExternalServiceURL: getElasticsearchExternalURL, diff --git a/pkg/controller/association/controller/kb_monitoring.go b/pkg/controller/association/controller/kb_monitoring.go index 494dcf7858d..c3ef331a413 100644 --- a/pkg/controller/association/controller/kb_monitoring.go +++ b/pkg/controller/association/controller/kb_monitoring.go @@ -26,6 +26,7 @@ import ( func AddKbMonitoring(mgr manager.Manager, accessReviewer rbac.AccessReviewer, params operator.Parameters) error { return association.AddAssociationController(mgr, accessReviewer, params, association.AssociationInfo{ AssociatedObjTemplate: func() commonv1.Associated { return &kbv1.Kibana{} }, + AssociatedObjListTemplate: func() client.ObjectList { return &kbv1.KibanaList{} }, ReferencedObjTemplate: func() client.Object { return &esv1.Elasticsearch{} }, ReferencedResourceVersion: referencedElasticsearchStatusVersion, ExternalServiceURL: getElasticsearchExternalURL, diff --git a/pkg/controller/association/controller/kibana_ent.go b/pkg/controller/association/controller/kibana_ent.go index a491b97303c..0c826043e55 100644 --- a/pkg/controller/association/controller/kibana_ent.go +++ b/pkg/controller/association/controller/kibana_ent.go @@ -25,6 +25,7 @@ import ( func AddKibanaEnt(mgr manager.Manager, accessReviewer rbac.AccessReviewer, params operator.Parameters) error { return association.AddAssociationController(mgr, accessReviewer, params, association.AssociationInfo{ AssociatedObjTemplate: func() commonv1.Associated { return &kbv1.Kibana{} }, + AssociatedObjListTemplate: func() client.ObjectList { return &kbv1.KibanaList{} }, ReferencedObjTemplate: func() client.Object { return &entv1.EnterpriseSearch{} }, ExternalServiceURL: getEntExternalURL, ReferencedResourceVersion: referencedEntStatusVersion, diff --git a/pkg/controller/association/controller/kibana_epr.go b/pkg/controller/association/controller/kibana_epr.go index cfaf23a7868..2569c1fb2c4 100644 --- a/pkg/controller/association/controller/kibana_epr.go +++ b/pkg/controller/association/controller/kibana_epr.go @@ -26,6 +26,7 @@ import ( func AddKibanaEPR(mgr manager.Manager, accessReviewer rbac.AccessReviewer, params operator.Parameters) error { return association.AddAssociationController(mgr, accessReviewer, params, association.AssociationInfo{ AssociatedObjTemplate: func() commonv1.Associated { return &kbv1.Kibana{} }, + AssociatedObjListTemplate: func() client.ObjectList { return &kbv1.KibanaList{} }, ReferencedObjTemplate: func() client.Object { return &eprv1alpha1.PackageRegistry{} }, ExternalServiceURL: getEPRExternalURL, ReferencedResourceVersion: referencedEPRStatusVersion, diff --git a/pkg/controller/association/controller/kibana_es.go b/pkg/controller/association/controller/kibana_es.go index df2ea602618..77152b471c4 100644 --- a/pkg/controller/association/controller/kibana_es.go +++ b/pkg/controller/association/controller/kibana_es.go @@ -43,6 +43,7 @@ const ( func AddKibanaES(mgr manager.Manager, accessReviewer rbac.AccessReviewer, params operator.Parameters) error { return association.AddAssociationController(mgr, accessReviewer, params, association.AssociationInfo{ AssociatedObjTemplate: func() commonv1.Associated { return &kbv1.Kibana{} }, + AssociatedObjListTemplate: func() client.ObjectList { return &kbv1.KibanaList{} }, ReferencedObjTemplate: func() client.Object { return &esv1.Elasticsearch{} }, ReferencedResourceVersion: referencedElasticsearchStatusVersion, ExternalServiceURL: getElasticsearchExternalURL, diff --git a/pkg/controller/association/controller/logstash_es.go b/pkg/controller/association/controller/logstash_es.go index 571845e16e0..6490e0a4750 100644 --- a/pkg/controller/association/controller/logstash_es.go +++ b/pkg/controller/association/controller/logstash_es.go @@ -36,6 +36,7 @@ func AddLogstashES(mgr manager.Manager, accessReviewer rbac.AccessReviewer, para return association.AddAssociationController(mgr, accessReviewer, params, association.AssociationInfo{ AssociationType: commonv1.ElasticsearchAssociationType, AssociatedObjTemplate: func() commonv1.Associated { return &logstashv1alpha1.Logstash{} }, + AssociatedObjListTemplate: func() client.ObjectList { return &logstashv1alpha1.LogstashList{} }, ReferencedObjTemplate: func() client.Object { return &esv1.Elasticsearch{} }, ReferencedResourceVersion: referencedElasticsearchStatusVersion, ExternalServiceURL: getElasticsearchExternalURL, diff --git a/pkg/controller/association/controller/logstash_monitoring.go b/pkg/controller/association/controller/logstash_monitoring.go index 5d288341dcb..8e6f63e5fa5 100644 --- a/pkg/controller/association/controller/logstash_monitoring.go +++ b/pkg/controller/association/controller/logstash_monitoring.go @@ -26,6 +26,7 @@ import ( func AddLogstashMonitoring(mgr manager.Manager, accessReviewer rbac.AccessReviewer, params operator.Parameters) error { return association.AddAssociationController(mgr, accessReviewer, params, association.AssociationInfo{ AssociatedObjTemplate: func() commonv1.Associated { return &logstashv1alpha1.Logstash{} }, + AssociatedObjListTemplate: func() client.ObjectList { return &logstashv1alpha1.LogstashList{} }, ReferencedObjTemplate: func() client.Object { return &esv1.Elasticsearch{} }, ReferencedResourceVersion: referencedElasticsearchStatusVersion, ExternalServiceURL: getElasticsearchExternalURL, diff --git a/pkg/controller/association/controller/maps_es.go b/pkg/controller/association/controller/maps_es.go index a467db61077..ff4b0d7bf2a 100644 --- a/pkg/controller/association/controller/maps_es.go +++ b/pkg/controller/association/controller/maps_es.go @@ -38,6 +38,7 @@ const ( func AddMapsES(mgr manager.Manager, accessReviewer rbac.AccessReviewer, params operator.Parameters) error { return association.AddAssociationController(mgr, accessReviewer, params, association.AssociationInfo{ AssociatedObjTemplate: func() commonv1.Associated { return &emsv1alpha1.ElasticMapsServer{} }, + AssociatedObjListTemplate: func() client.ObjectList { return &emsv1alpha1.ElasticMapsServerList{} }, ReferencedObjTemplate: func() client.Object { return &esv1.Elasticsearch{} }, ReferencedResourceVersion: referencedElasticsearchStatusVersion, ExternalServiceURL: getElasticsearchExternalURL, diff --git a/pkg/controller/association/controller_test.go b/pkg/controller/association/controller_test.go new file mode 100644 index 00000000000..aaff9e58621 --- /dev/null +++ b/pkg/controller/association/controller_test.go @@ -0,0 +1,76 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. + +package association + +import ( + "context" + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + apmv1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/apm/v1" + commonv1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/common/v1" + cachemock "github.com/elastic/cloud-on-k8s/v3/pkg/utils/test/mock" +) + +func Test_namespaceFlipRequests(t *testing.T) { + apm := func(name, namespace string, esRef, kbRef commonv1.ObjectSelector) *apmv1.ApmServer { + return &apmv1.ApmServer{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, + Spec: apmv1.ApmServerSpec{ + ElasticsearchRef: commonv1.ElasticsearchSelector{ObjectSelector: esRef}, + KibanaRef: kbRef, + }, + } + } + noRef := commonv1.ObjectSelector{} + + // "descoped" is the namespace whose match state just changed, "scoped" is a namespace + // that remains in scope. + objs := []client.Object{ + // lives in the descoped namespace: enqueued regardless of refs + apm("in-descoped", "descoped", noRef, noRef), + // in a scoped namespace, references an ES in the descoped namespace: enqueued + apm("cross-ref", "scoped", commonv1.ObjectSelector{Name: "es", Namespace: "descoped"}, noRef), + // in the descoped namespace with an implicit same-namespace ref: enqueued exactly once + apm("default-ns-ref", "descoped", commonv1.ObjectSelector{Name: "es"}, noRef), + // references an ES in an unrelated namespace: not enqueued + apm("unrelated", "scoped", commonv1.ObjectSelector{Name: "es", Namespace: "elsewhere"}, noRef), + // references the descoped namespace, but through an association of another type: not enqueued + apm("wrong-type", "scoped", noRef, commonv1.ObjectSelector{Name: "kb", Namespace: "descoped"}), + } + + r := &Reconciler{ + AssociationInfo: AssociationInfo{ + AssociationType: commonv1.ElasticsearchAssociationType, + AssociatedObjListTemplate: func() client.ObjectList { return &apmv1.ApmServerList{} }, + }, + } + + c := cachemock.NewCache(t) + c.On("List", mock.Anything, mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + list := args.Get(1).(*apmv1.ApmServerList) //nolint:forcetypeassert + for _, obj := range objs { + list.Items = append(list.Items, *obj.(*apmv1.ApmServer)) //nolint:forcetypeassert + } + }).Return(nil) + + reqs := namespaceFlipRequests(c, r)( + context.Background(), + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "descoped"}}, + ) + + require.ElementsMatch(t, []reconcile.Request{ + {NamespacedName: types.NamespacedName{Namespace: "descoped", Name: "in-descoped"}}, + {NamespacedName: types.NamespacedName{Namespace: "scoped", Name: "cross-ref"}}, + {NamespacedName: types.NamespacedName{Namespace: "descoped", Name: "default-ns-ref"}}, + }, reqs) +} diff --git a/pkg/controller/association/reconciler.go b/pkg/controller/association/reconciler.go index 75fde03de88..5f2240c88a1 100644 --- a/pkg/controller/association/reconciler.go +++ b/pkg/controller/association/reconciler.go @@ -40,9 +40,7 @@ import ( "github.com/elastic/cloud-on-k8s/v3/pkg/utils/rbac" ) -var ( - defaultRequeue = reconcile.Result{RequeueAfter: reconciler.DefaultRequeue} -) +var defaultRequeue = reconcile.Result{RequeueAfter: reconciler.DefaultRequeue} // AssociationInfo contains information specific to a particular associated resource (eg. Kibana, APMServer, etc.). type AssociationInfo struct { //nolint:revive @@ -51,6 +49,9 @@ type AssociationInfo struct { //nolint:revive AssociationType commonv1.AssociationType // AssociatedObjTemplate builds an empty typed associated object (eg. &Kibana{} for a Kibana to Elasticsearch association). AssociatedObjTemplate func() commonv1.Associated + // AssociatedObjListTemplate builds an empty typed list of associated objects (e.g. &KibanaList{}). + // Used by WatchNamespaceFlips to re-enqueue all associated objects when a namespace state change to match. + AssociatedObjListTemplate func() client.ObjectList // ReferencedObjTemplate builds an empty referenced object (e.g. Elasticsearch{} for a Kibana to Elasticsearch association). ReferencedObjTemplate func() client.Object // ReferencedResourceNamer is used to build the name of the Secret which contains the CA of the referenced resource @@ -153,6 +154,10 @@ type Reconciler struct { } func (r *Reconciler) Reconcile(ctx context.Context, request reconcile.Request) (reconcile.Result, error) { + if !r.NamespaceMatcher.Matches(request.Namespace) { + r.onNamespaceOutOfScope(types.NamespacedName{Namespace: request.Namespace, Name: request.Name}) + return reconcile.Result{}, nil + } nameField := fmt.Sprintf("%s_name", r.AssociatedShortName) ctx = common.NewReconciliationContext(ctx, &r.iteration, r.Tracer, r.AssociationName, nameField, request) defer common.LogReconciliationRun(ulog.FromContext(ctx))() @@ -570,9 +575,12 @@ func resultFromStatuses(statusMap commonv1.AssociationStatusMap) reconcile.Resul return reconcile.Result{} // we are done or there is not much we can do } -func (r *Reconciler) onDelete(ctx context.Context, associated types.NamespacedName) { - // remove watches +func (r *Reconciler) onNamespaceOutOfScope(associated types.NamespacedName) { r.removeWatches(associated) +} + +func (r *Reconciler) onDelete(ctx context.Context, associated types.NamespacedName) { + r.onNamespaceOutOfScope(associated) // delete user Secret in the Elasticsearch namespace if err := deleteOrphanedResources(ctx, r.Client, r.AssociationInfo, associated, nil); err != nil { diff --git a/pkg/controller/autoops/controller.go b/pkg/controller/autoops/controller.go index d5c3e43887d..ff2cc0f5ff9 100644 --- a/pkg/controller/autoops/controller.go +++ b/pkg/controller/autoops/controller.go @@ -9,18 +9,19 @@ import ( "fmt" "sync/atomic" + "github.com/go-logr/logr" "go.elastic.co/apm/v2" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/types" toolsevents "k8s.io/client-go/tools/events" + "sigs.k8s.io/controller-runtime/pkg/cache" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/controller-runtime/pkg/reconcile" - "sigs.k8s.io/controller-runtime/pkg/source" autoopsv1alpha1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/autoops/v1alpha1" esv1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/elasticsearch/v1" @@ -67,23 +68,55 @@ func newReconciler(mgr manager.Manager, accessReviewer rbac.AccessReviewer, para } func addWatches(mgr manager.Manager, c controller.Controller, r *AgentPolicyReconciler) error { + log := ulog.Log // no context available for contextual logging + + m := r.params.NamespaceMatcher // watch for changes to AutoOpsAgentPolicy - if err := c.Watch(source.Kind(mgr.GetCache(), &autoopsv1alpha1.AutoOpsAgentPolicy{}, &handler.TypedEnqueueRequestForObject[*autoopsv1alpha1.AutoOpsAgentPolicy]{})); err != nil { + if err := c.Watch(watches.NamespacedKind(m, mgr.GetCache(), &autoopsv1alpha1.AutoOpsAgentPolicy{}, &handler.TypedEnqueueRequestForObject[*autoopsv1alpha1.AutoOpsAgentPolicy]{})); err != nil { return err } // watch for changes to Elasticsearch and reconcile all AutoOpsAgentPolicies - if err := c.Watch(source.Kind[client.Object](mgr.GetCache(), &esv1.Elasticsearch{}, reconcileRequestForAllAutoOpsPolicies(r.Client))); err != nil { + if err := c.Watch(watches.NamespacedKind[client.Object](m, mgr.GetCache(), &esv1.Elasticsearch{}, reconcileRequestForAllAutoOpsPolicies(r.Client))); err != nil { return err } // watch dynamically referenced secrets - if err := c.Watch(source.Kind(mgr.GetCache(), &corev1.Secret{}, r.dynamicWatches.Secrets)); err != nil { + if err := c.Watch(watches.NamespacedKind(m, mgr.GetCache(), &corev1.Secret{}, r.dynamicWatches.Secrets)); err != nil { return err } // watch for changes to deployments created by this controller - return c.Watch(source.Kind(mgr.GetCache(), &appsv1.Deployment{}, reconcileRequestForAutoOpsPolicyFromDeployment())) + if err := c.Watch(watches.NamespacedKind(m, mgr.GetCache(), &appsv1.Deployment{}, reconcileRequestForAutoOpsPolicyFromDeployment())); err != nil { + return err + } + return watches.WatchNamespaceFlipsMapped(c, m, namespaceFlipRequests(log, mgr.GetCache())) +} + +// namespaceFlipRequests returns a mapper translating a namespace match-state change into +// reconcile requests for the AutoOpsAgentPolicies affected by it. A policy selects its +// Elasticsearch clusters cluster-wide by label (ResourceSelector), so a flip of any namespace +// can change any policy's set of matching clusters: policies living in the flipped namespace +// must be picked up or cleaned up, and policies living elsewhere must deploy agents for newly +// scoped clusters or clean up agents and API keys for de-scoped ones. Mirroring the +// Elasticsearch watch above (reconcileRequestForAllAutoOpsPolicies), all policies are +// re-enqueued. +func namespaceFlipRequests(log logr.Logger, cache cache.Cache) func(context.Context, *corev1.Namespace) []reconcile.Request { + return func(ctx context.Context, ns *corev1.Namespace) []reconcile.Request { + var list autoopsv1alpha1.AutoOpsAgentPolicyList + // List **cluster-wide** from the cache (not the FilterClient): policies in the + // namespace being de-scoped would be hidden by the FilterClient, causing us to miss + // the reconcile requests needed to clean them up. + if err := cache.List(ctx, &list); err != nil { + log.Error(err, "failed to list AutoOpsAgentPolicies in namespace flip watch", "namespace", ns.Name) + return nil + } + reqs := make([]reconcile.Request, 0, len(list.Items)) + for i := range list.Items { + reqs = append(reqs, reconcile.Request{NamespacedName: k8s.ExtractNamespacedName(&list.Items[i])}) + } + return reqs + } } // reconcileRequestForAllAutoOpsPolicies returns the requests to reconcile all AutoOpsAgentPolicy resources. @@ -138,6 +171,10 @@ type AgentPolicyReconciler struct { // Reconcile reconciles the AutoOpsAgentPolicy resource ensuring that any resources are created/updated/deleted as needed. func (r *AgentPolicyReconciler) Reconcile(ctx context.Context, request reconcile.Request) (reconcile.Result, error) { + if !r.params.NamespaceMatcher.Matches(request.Namespace) { + r.onNamespaceOutOfScope(request.NamespacedName) + return reconcile.Result{}, nil + } ctx = common.NewReconciliationContext(ctx, &r.iteration, r.params.Tracer, controllerName, "policy_name", request) defer common.LogReconciliationRun(ulog.FromContext(ctx))() defer tracing.EndContextTransaction(ctx) @@ -221,13 +258,16 @@ func (r *AgentPolicyReconciler) updateStatusFromState(ctx context.Context, state return reconcile.Result{}, nil } +func (r *AgentPolicyReconciler) onNamespaceOutOfScope(obj types.NamespacedName) { + r.dynamicWatches.Secrets.RemoveHandlerForKey(configSecretWatchName(obj)) +} + func (r *AgentPolicyReconciler) onDelete(ctx context.Context, obj types.NamespacedName) error { defer tracing.Span(&ctx)() log := ulog.FromContext(ctx) log.Info("Cleaning up AutoOpsAgentPolicy resources") - // Remove dynamic watches on secrets - r.dynamicWatches.Secrets.RemoveHandlerForKey(configSecretWatchName(obj)) + r.onNamespaceOutOfScope(obj) // Cleanup API keys for all Elasticsearch clusters that match this policy. // Query for secrets labeled with this policy to find all associated ES clusters. diff --git a/pkg/controller/autoops/namespace_flip_test.go b/pkg/controller/autoops/namespace_flip_test.go new file mode 100644 index 00000000000..4a8a2d85355 --- /dev/null +++ b/pkg/controller/autoops/namespace_flip_test.go @@ -0,0 +1,74 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. + +package autoops + +import ( + "context" + "testing" + + "github.com/go-logr/logr" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + autoopsv1alpha1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/autoops/v1alpha1" + cachemock "github.com/elastic/cloud-on-k8s/v3/pkg/utils/test/mock" +) + +func Test_namespaceFlipRequests(t *testing.T) { + policy := func(name, namespace string) autoopsv1alpha1.AutoOpsAgentPolicy { + return autoopsv1alpha1.AutoOpsAgentPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, + } + } + req := func(namespace, name string) reconcile.Request { + return reconcile.Request{NamespacedName: types.NamespacedName{Namespace: namespace, Name: name}} + } + + // Policies select Elasticsearch clusters cluster-wide by label, so a flip of any + // namespace can change any policy's set of matching clusters: all policies are + // re-enqueued, wherever they live. + tests := []struct { + name string + policies []autoopsv1alpha1.AutoOpsAgentPolicy + want []reconcile.Request + }{ + { + name: "all policies are enqueued, both in and outside the flipped namespace", + policies: []autoopsv1alpha1.AutoOpsAgentPolicy{ + policy("local", "flipped"), + policy("elsewhere", "scoped"), + }, + want: []reconcile.Request{ + req("flipped", "local"), + req("scoped", "elsewhere"), + }, + }, + { + name: "no policies", + want: []reconcile.Request{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := cachemock.NewCache(t) + c.On("List", mock.Anything, mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + list := args.Get(1).(*autoopsv1alpha1.AutoOpsAgentPolicyList) //nolint:forcetypeassert + list.Items = append(list.Items, tt.policies...) + }).Return(nil) + + reqs := namespaceFlipRequests(logr.Discard(), c)( + context.Background(), + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "flipped"}}, + ) + + require.ElementsMatch(t, tt.want, reqs) + }) + } +} diff --git a/pkg/controller/autoops/validation/webhook.go b/pkg/controller/autoops/validation/webhook.go index dcd0071db25..8819106d0b0 100644 --- a/pkg/controller/autoops/validation/webhook.go +++ b/pkg/controller/autoops/validation/webhook.go @@ -16,6 +16,7 @@ import ( autoopsv1alpha1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/autoops/v1alpha1" commonv1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/common/v1" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/license" + "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/nsmatch" commonwebhook "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/webhook" ulog "github.com/elastic/cloud-on-k8s/v3/pkg/utils/log" ) @@ -29,13 +30,13 @@ const ( var autoopslog = ulog.Log.WithName("autoops-validation") // RegisterWebhook registers the AutoOpsAgentPolicy validating webhook with the manager. -func RegisterWebhook(mgr ctrl.Manager, licenseChecker license.Checker, managedNamespaces []string) { +func RegisterWebhook(mgr ctrl.Manager, licenseChecker license.Checker, managedNamespaces []string, matcher *nsmatch.NamespaceMatcher) { autoopsValidator := &validator{ licenseChecker: licenseChecker, } // License checks run inside validations(), so we pass nil here // (the reconciler calls Validate directly). - v := commonwebhook.NewResourceValidator[*autoopsv1alpha1.AutoOpsAgentPolicy](nil, managedNamespaces, autoopsValidator) + v := commonwebhook.NewResourceValidator[*autoopsv1alpha1.AutoOpsAgentPolicy](nil, managedNamespaces, autoopsValidator).WithNamespaceMatcher(matcher) autoopslog.Info("Registering AutoOpsAgentPolicy validating webhook", "path", webhookPath) wh := admission.WithValidator[*autoopsv1alpha1.AutoOpsAgentPolicy](mgr.GetScheme(), v) mgr.GetWebhookServer().Register(webhookPath, wh) diff --git a/pkg/controller/autoscaling/elasticsearch.go b/pkg/controller/autoscaling/elasticsearch.go index a4455f816a2..52c6213d9a4 100644 --- a/pkg/controller/autoscaling/elasticsearch.go +++ b/pkg/controller/autoscaling/elasticsearch.go @@ -8,13 +8,13 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/manager" - "sigs.k8s.io/controller-runtime/pkg/source" "github.com/elastic/cloud-on-k8s/v3/pkg/apis/autoscaling/v1alpha1" esv1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/elasticsearch/v1" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/autoscaling/elasticsearch" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/operator" + "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/watches" ) // Add creates a new Elasticsearch autoscaling controllers, and adds it to the Manager with default RBAC. @@ -28,8 +28,14 @@ func Add(mgr manager.Manager, p operator.Parameters) error { if err != nil { return err } - if err := controller.Watch(source.Kind(mgr.GetCache(), &v1alpha1.ElasticsearchAutoscaler{}, &handler.TypedEnqueueRequestForObject[*v1alpha1.ElasticsearchAutoscaler]{})); err != nil { + m := p.NamespaceMatcher + if err := controller.Watch(watches.NamespacedKind(m, mgr.GetCache(), &v1alpha1.ElasticsearchAutoscaler{}, &handler.TypedEnqueueRequestForObject[*v1alpha1.ElasticsearchAutoscaler]{})); err != nil { return err } - return controller.Watch(source.Kind[client.Object](mgr.GetCache(), &esv1.Elasticsearch{}, reconciler.Watches.ReferencedResources)) + if err := controller.Watch(watches.NamespacedKind[client.Object](m, mgr.GetCache(), &esv1.Elasticsearch{}, reconciler.Watches.ReferencedResources)); err != nil { + return err + } + return watches.WatchNamespaceFlips(controller, mgr.GetCache(), p.NamespaceMatcher, func() client.ObjectList { + return &v1alpha1.ElasticsearchAutoscalerList{} + }) } diff --git a/pkg/controller/autoscaling/elasticsearch/controller.go b/pkg/controller/autoscaling/elasticsearch/controller.go index 6c89f15ffc6..ec5954c5f2f 100644 --- a/pkg/controller/autoscaling/elasticsearch/controller.go +++ b/pkg/controller/autoscaling/elasticsearch/controller.go @@ -97,11 +97,19 @@ func NewReconciler(mgr manager.Manager, params operator.Parameters) *ReconcileEl } } -func dynamicWatchName(request reconcile.Request) string { - return fmt.Sprintf("%s-%s-referenced-es-watch", request.Namespace, request.Name) +func dynamicWatchName(obj types.NamespacedName) string { + return fmt.Sprintf("%s-%s-referenced-es-watch", obj.Namespace, obj.Name) +} + +func (r *ReconcileElasticsearchAutoscaler) onNamespaceOutOfScope(obj types.NamespacedName) { + r.Watches.ReferencedResources.RemoveHandlerForKey(dynamicWatchName(obj)) } func (r *ReconcileElasticsearchAutoscaler) Reconcile(ctx context.Context, request reconcile.Request) (reconcile.Result, error) { + if !r.NamespaceMatcher.Matches(request.Namespace) { + r.onNamespaceOutOfScope(request.NamespacedName) + return reconcile.Result{}, nil + } ctx = common.NewReconciliationContext(ctx, &r.iteration, r.Tracer, ControllerName, "esa_name", request) defer common.LogReconciliationRun(logconf.FromContext(ctx))() defer tracing.EndContextTransaction(ctx) @@ -112,7 +120,7 @@ func (r *ReconcileElasticsearchAutoscaler) Reconcile(ctx context.Context, reques if err := r.Get(ctx, request.NamespacedName, &esa); err != nil { if apierrors.IsNotFound(err) { log.V(1).Info("ElasticsearchAutoscaler not found", "namespace", request.Namespace, "esa_name", request.Name) - r.Watches.ReferencedResources.RemoveHandlerForKey(dynamicWatchName(request)) + r.onNamespaceOutOfScope(request.NamespacedName) return reconcile.Result{}, nil } return reconcile.Result{}, tracing.CaptureError(ctx, err) @@ -121,7 +129,7 @@ func (r *ReconcileElasticsearchAutoscaler) Reconcile(ctx context.Context, reques // Ensure we watch the associated Elasticsearch esNamespacedName := types.NamespacedName{Name: esa.Spec.ElasticsearchRef.Name, Namespace: request.Namespace} if err := r.Watches.ReferencedResources.AddHandler(watches.NamedWatch[client.Object]{ - Name: dynamicWatchName(request), + Name: dynamicWatchName(request.NamespacedName), Watched: []types.NamespacedName{esNamespacedName}, Watcher: request.NamespacedName, }); err != nil { diff --git a/pkg/controller/autoscaling/elasticsearch/validation/webhook.go b/pkg/controller/autoscaling/elasticsearch/validation/webhook.go index c0a874fce33..5376d2d1fba 100644 --- a/pkg/controller/autoscaling/elasticsearch/validation/webhook.go +++ b/pkg/controller/autoscaling/elasticsearch/validation/webhook.go @@ -15,6 +15,7 @@ import ( "github.com/elastic/cloud-on-k8s/v3/pkg/apis/autoscaling/v1alpha1" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/license" + "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/nsmatch" commonwebhook "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/webhook" "github.com/elastic/cloud-on-k8s/v3/pkg/utils/k8s" ulog "github.com/elastic/cloud-on-k8s/v3/pkg/utils/log" @@ -29,7 +30,7 @@ const ( var esalog = ulog.Log.WithName("esa-validation") // RegisterWebhook registers the ElasticsearchAutoscaler validating webhook. -func RegisterWebhook(mgr ctrl.Manager, validateStorageClass bool, licenseChecker license.Checker, managedNamespaces []string) { +func RegisterWebhook(mgr ctrl.Manager, validateStorageClass bool, licenseChecker license.Checker, managedNamespaces []string, matcher *nsmatch.NamespaceMatcher) { inner := &validator{ client: mgr.GetClient(), validateStorageClass: validateStorageClass, @@ -37,7 +38,7 @@ func RegisterWebhook(mgr ctrl.Manager, validateStorageClass bool, licenseChecker } // License checks run inside validations(), so we pass nil here // (the reconciler calls ValidateElasticsearchAutoscaler directly). - v := commonwebhook.NewResourceValidator[*v1alpha1.ElasticsearchAutoscaler](nil, managedNamespaces, inner) + v := commonwebhook.NewResourceValidator[*v1alpha1.ElasticsearchAutoscaler](nil, managedNamespaces, inner).WithNamespaceMatcher(matcher) esalog.Info("Registering ElasticsearchAutoscaler validating webhook", "path", webhookPath) wh := admission.WithValidator[*v1alpha1.ElasticsearchAutoscaler](mgr.GetScheme(), v) mgr.GetWebhookServer().Register(webhookPath, wh) diff --git a/pkg/controller/beat/controller.go b/pkg/controller/beat/controller.go index 9b57967f358..7e0557cddfe 100644 --- a/pkg/controller/beat/controller.go +++ b/pkg/controller/beat/controller.go @@ -13,11 +13,11 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/types" toolsevents "k8s.io/client-go/tools/events" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/controller-runtime/pkg/reconcile" - "sigs.k8s.io/controller-runtime/pkg/source" beatv1beta1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/beat/v1beta1" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/association" @@ -68,13 +68,14 @@ func newReconciler(mgr manager.Manager, params operator.Parameters) *ReconcileBe // addWatches adds watches for all resources this controller cares about func addWatches(mgr manager.Manager, c controller.Controller, r *ReconcileBeat) error { + m := r.NamespaceMatcher // Watch for changes to Beat - if err := c.Watch(source.Kind(mgr.GetCache(), &beatv1beta1.Beat{}, &handler.TypedEnqueueRequestForObject[*beatv1beta1.Beat]{})); err != nil { + if err := c.Watch(watches.NamespacedKind(m, mgr.GetCache(), &beatv1beta1.Beat{}, &handler.TypedEnqueueRequestForObject[*beatv1beta1.Beat]{})); err != nil { return err } // Watch DaemonSets - if err := c.Watch(source.Kind(mgr.GetCache(), &appsv1.DaemonSet{}, handler.TypedEnqueueRequestForOwner[*appsv1.DaemonSet]( + if err := c.Watch(watches.NamespacedKind(m, mgr.GetCache(), &appsv1.DaemonSet{}, handler.TypedEnqueueRequestForOwner[*appsv1.DaemonSet]( mgr.GetScheme(), mgr.GetRESTMapper(), &beatv1beta1.Beat{}, handler.OnlyControllerOwner(), ))); err != nil { @@ -82,7 +83,7 @@ func addWatches(mgr manager.Manager, c controller.Controller, r *ReconcileBeat) } // Watch Deployments - if err := c.Watch(source.Kind(mgr.GetCache(), &appsv1.Deployment{}, handler.TypedEnqueueRequestForOwner[*appsv1.Deployment]( + if err := c.Watch(watches.NamespacedKind(m, mgr.GetCache(), &appsv1.Deployment{}, handler.TypedEnqueueRequestForOwner[*appsv1.Deployment]( mgr.GetScheme(), mgr.GetRESTMapper(), &beatv1beta1.Beat{}, handler.OnlyControllerOwner(), ))); err != nil { @@ -91,23 +92,28 @@ func addWatches(mgr manager.Manager, c controller.Controller, r *ReconcileBeat) // Watch Pods, to ensure `status.version` is correctly reconciled on any change. // Watching Deployments or DaemonSets only may lead to missing some events. - if err := watches.WatchPods(mgr, c, beatcommon.NameLabelName); err != nil { + if err := watches.WatchPods(mgr, c, m, beatcommon.NameLabelName); err != nil { return err } // Watch owned and soft-owned Secrets - if err := c.Watch(source.Kind(mgr.GetCache(), &corev1.Secret{}, handler.TypedEnqueueRequestForOwner[*corev1.Secret]( + if err := c.Watch(watches.NamespacedKind(m, mgr.GetCache(), &corev1.Secret{}, handler.TypedEnqueueRequestForOwner[*corev1.Secret]( mgr.GetScheme(), mgr.GetRESTMapper(), &beatv1beta1.Beat{}, handler.OnlyControllerOwner(), ))); err != nil { return err } - if err := watches.WatchSoftOwnedSecrets(mgr, c, beatv1beta1.Kind); err != nil { + if err := watches.WatchSoftOwnedSecrets(mgr, c, m, beatv1beta1.Kind); err != nil { return err } // Watch dynamically referenced Secrets - return c.Watch(source.Kind(mgr.GetCache(), &corev1.Secret{}, r.dynamicWatches.Secrets)) + if err := c.Watch(watches.NamespacedKind(m, mgr.GetCache(), &corev1.Secret{}, r.dynamicWatches.Secrets)); err != nil { + return err + } + return watches.WatchNamespaceFlips(c, mgr.GetCache(), r.NamespaceMatcher, func() client.ObjectList { + return &beatv1beta1.BeatList{} + }) } var _ reconcile.Reconciler = (*ReconcileBeat)(nil) @@ -125,6 +131,10 @@ type ReconcileBeat struct { // Reconcile reads that state of the cluster for a Beat object and makes changes based on the state read // and what is in the Beat.Spec. func (r *ReconcileBeat) Reconcile(ctx context.Context, request reconcile.Request) (reconcile.Result, error) { + if !r.NamespaceMatcher.Matches(request.Namespace) { + r.onNamespaceOutOfScope(request.NamespacedName) + return reconcile.Result{}, nil + } ctx = common.NewReconciliationContext(ctx, &r.iteration, r.Tracer, controllerName, "beat_name", request) defer common.LogReconciliationRun(ulog.FromContext(ctx))() defer tracing.EndContextTransaction(ctx) @@ -203,9 +213,13 @@ func (r *ReconcileBeat) validate(ctx context.Context, beat *beatv1beta1.Beat) er return nil } -func (r *ReconcileBeat) onDelete(ctx context.Context, obj types.NamespacedName) error { +func (r *ReconcileBeat) onNamespaceOutOfScope(obj types.NamespacedName) { r.dynamicWatches.Secrets.RemoveHandlerForKey(keystore.SecureSettingsWatchName(obj)) r.dynamicWatches.Secrets.RemoveHandlerForKey(common.ConfigRefWatchName(obj)) +} + +func (r *ReconcileBeat) onDelete(ctx context.Context, obj types.NamespacedName) error { + r.onNamespaceOutOfScope(obj) return reconciler.GarbageCollectSoftOwnedSecrets(ctx, r.Client, obj, beatv1beta1.Kind) } diff --git a/pkg/controller/common/keystore/user_secret.go b/pkg/controller/common/keystore/user_secret.go index d3f62df6619..1babf5f4c2d 100644 --- a/pkg/controller/common/keystore/user_secret.go +++ b/pkg/controller/common/keystore/user_secret.go @@ -103,7 +103,8 @@ func reconcileSecureSettings( hasKeystore HasKeystore, userSecrets []corev1.Secret, namer name.Namer, - meta metadata.Metadata) (*corev1.Secret, error) { + meta metadata.Metadata, +) (*corev1.Secret, error) { aggregatedData := map[string][]byte{} for _, s := range userSecrets { diff --git a/pkg/controller/common/license/event.go b/pkg/controller/common/license/event.go new file mode 100644 index 00000000000..5359c81fb25 --- /dev/null +++ b/pkg/controller/common/license/event.go @@ -0,0 +1,32 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. + +package license + +import ( + "os" + + corev1 "k8s.io/api/core/v1" + toolsevents "k8s.io/client-go/tools/events" + + "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/events" + "github.com/elastic/cloud-on-k8s/v3/pkg/utils/k8s" +) + +// EmitEnterpriseFeatureEvent emits a Warning event on the operator's own Pod +// when an enterprise feature is used without a valid enterprise license. +// The pod name is resolved via os.Hostname(), which Kubernetes always sets to +// the pod name. If the hostname cannot be determined the call is a no-op. +func EmitEnterpriseFeatureEvent(recorder toolsevents.EventRecorder, operatorNS, msg string) { + podName, err := os.Hostname() + if err != nil || podName == "" { + return + } + pod := corev1.Pod{} + pod.APIVersion = "v1" + pod.Kind = "Pod" + pod.Name = podName + pod.Namespace = operatorNS + k8s.EmitEvent(recorder, &pod, corev1.EventTypeWarning, EventInvalidLicense, events.EventActionLicenseCheck, msg) +} diff --git a/pkg/controller/common/nsmatch/filter_client.go b/pkg/controller/common/nsmatch/filter_client.go new file mode 100644 index 00000000000..516c36caf8c --- /dev/null +++ b/pkg/controller/common/nsmatch/filter_client.go @@ -0,0 +1,101 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. + +package nsmatch + +import ( + "context" + "slices" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + apimeta "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/apiutil" +) + +// Currently the only filtering axis is namespace; the design can be extended to +// support additional predicate types if needed in the future (and in that case it can be exported to a different package). + +// FilterClient wraps a cache-backed client.Client and filters List results by +// namespace using a NamespaceFlipNotifier. It delegates all other operations to +// the underlying client unchanged. +type FilterClient struct { + client.Client + nfn *NamespaceMatcher +} + +// NewFilterClient returns a WrappedClient backed by the provided delegate. +func NewFilterClient(delegate client.Client, nfn *NamespaceMatcher) *FilterClient { + return &FilterClient{Client: delegate, nfn: nfn} +} + +// Get overrides the delegate's Get to apply namespace-selector filtering. When the +// requested key's namespace does not match, a synthetic NotFound error is returned +// without querying the delegate, so the caller treats the object as invisible, +// consistent with how List filters it out. As with a NotFound returned by the API +// server, obj is left untouched: callers must not rely on it being populated when +// an error is returned. Cluster-scoped objects have an empty key.Namespace, which +// always matches. +func (w *FilterClient) Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + if w.nfn.SelectorEnabled() && !w.nfn.Matches(key.Namespace) { + return apierrors.NewNotFound(w.groupResource(obj), key.Name) + } + return w.Client.Get(ctx, key, obj, opts...) +} + +// groupResource best-effort resolves the GroupResource of obj so that the synthetic +// NotFound error renders like one returned by the API server. Falls back to an empty +// (or group-only) GroupResource when the type is not registered in the scheme or the +// RESTMapper; apierrors.IsNotFound is unaffected either way. +func (w *FilterClient) groupResource(obj client.Object) schema.GroupResource { + gvk, err := apiutil.GVKForObject(obj, w.Scheme()) + if err != nil { + return schema.GroupResource{} + } + mapping, err := w.RESTMapper().RESTMapping(gvk.GroupKind(), gvk.Version) + if err != nil { + return schema.GroupResource{Group: gvk.Group} + } + return mapping.Resource.GroupResource() +} + +// List overrides the delegate's List to apply namespace-selector filtering on results. +func (w *FilterClient) List(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { + if err := w.Client.List(ctx, list, opts...); err != nil { + return err + } + if !w.nfn.SelectorEnabled() { + return nil + } + // Fast path for namespace-scoped lists: one Matches call decides the whole result. + listOpts := &client.ListOptions{} + for _, opt := range opts { + opt.ApplyToList(listOpts) + } + if listOpts.Namespace != "" { + if !w.nfn.Matches(listOpts.Namespace) { + return apimeta.SetList(list, nil) + } + return nil + } + return filterByNamespace(list, w.nfn.Matches) +} + +// filterByNamespace removes items from list whose namespace does not satisfy matches. +// Items are kept (fail-open) if their namespace cannot be determined, since an Accessor +// error is not expected for real list items and dropping objects on such an error would +// be surprising. +func filterByNamespace(list client.ObjectList, matches func(string) bool) error { + items, err := apimeta.ExtractList(list) + if err != nil { + return err + } + items = slices.DeleteFunc(items, func(item runtime.Object) bool { + acc, err := apimeta.Accessor(item) + return err == nil && !matches(acc.GetNamespace()) + }) + return apimeta.SetList(list, items) +} diff --git a/pkg/controller/common/nsmatch/filter_client_test.go b/pkg/controller/common/nsmatch/filter_client_test.go new file mode 100644 index 00000000000..8e3191d92f7 --- /dev/null +++ b/pkg/controller/common/nsmatch/filter_client_test.go @@ -0,0 +1,205 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. + +package nsmatch + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" +) + +// makeFilterClient builds a FilterClient whose notifier has sel active and the given namespaces pre-seeded as matching. +func makeFilterClient(delegate client.Client, sel labels.Selector, matchedNSes ...string) *FilterClient { + nfn := NewNamespaceMatcher(sel, testOperatorNS) + for _, ns := range matchedNSes { + nfn.Swap(ns, true) + } + return NewFilterClient(delegate, nfn) +} + +func pod(name, ns string) *corev1.Pod { + return &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns}} +} + +func podNames(list *corev1.PodList) []string { + names := make([]string, len(list.Items)) + for i, p := range list.Items { + names[i] = p.Name + } + return names +} + +// fakeClientListErr returns a fake.Client whose List calls always fail with err. +func fakeClientListErr(err error) client.Client { + return fake.NewClientBuilder().WithInterceptorFuncs(interceptor.Funcs{ + List: func(_ context.Context, _ client.WithWatch, _ client.ObjectList, _ ...client.ListOption) error { + return err + }, + }).Build() +} + +// fakeClientGetErr returns a fake.Client whose Get calls always fail with err. +func fakeClientGetErr(err error) client.Client { + return fake.NewClientBuilder().WithInterceptorFuncs(interceptor.Funcs{ + Get: func(_ context.Context, _ client.WithWatch, _ client.ObjectKey, _ client.Object, _ ...client.GetOption) error { + return err + }, + }).Build() +} + +func TestFilterClientList(t *testing.T) { + sel := mustSelector(t, map[string]string{"env": "prod"}) + ctx := context.Background() + + t.Run("delegate error is propagated without filtering", func(t *testing.T) { + fc := NewFilterClient(fakeClientListErr(errors.New("api server unavailable")), NewNamespaceMatcher(sel, testOperatorNS)) + require.Error(t, fc.List(ctx, &corev1.PodList{})) + }) + + t.Run("nil notifier: all items returned unfiltered", func(t *testing.T) { + fc := NewFilterClient(fake.NewClientBuilder().WithObjects(pod("a", "ns-a"), pod("b", "ns-b")).Build(), nil) + list := &corev1.PodList{} + require.NoError(t, fc.List(ctx, list)) + assert.Len(t, list.Items, 2) + }) + + t.Run("selector disabled: all items returned unfiltered", func(t *testing.T) { + fc := NewFilterClient(fake.NewClientBuilder().WithObjects(pod("a", "ns-a"), pod("b", "ns-b")).Build(), NewNamespaceMatcher(nil, testOperatorNS)) + list := &corev1.PodList{} + require.NoError(t, fc.List(ctx, list)) + assert.Len(t, list.Items, 2) + }) + + t.Run("namespace-scoped list, namespace matches: items unchanged", func(t *testing.T) { + fc := makeFilterClient(fake.NewClientBuilder().WithObjects(pod("a", "prod-ns"), pod("b", "prod-ns")).Build(), sel, "prod-ns") + list := &corev1.PodList{} + require.NoError(t, fc.List(ctx, list, client.InNamespace("prod-ns"))) + assert.Len(t, list.Items, 2) + }) + + t.Run("namespace-scoped list, namespace does not match: all items cleared", func(t *testing.T) { + fc := makeFilterClient(fake.NewClientBuilder().WithObjects(pod("a", "dev-ns"), pod("b", "dev-ns")).Build(), sel) // dev-ns not seeded + list := &corev1.PodList{} + require.NoError(t, fc.List(ctx, list, client.InNamespace("dev-ns"))) + assert.Empty(t, list.Items) + }) + + t.Run("cluster-scoped list, all namespaces match: all items kept", func(t *testing.T) { + fc := makeFilterClient(fake.NewClientBuilder().WithObjects(pod("a", "ns-1"), pod("b", "ns-2")).Build(), sel, "ns-1", "ns-2") + list := &corev1.PodList{} + require.NoError(t, fc.List(ctx, list)) + assert.ElementsMatch(t, []string{"a", "b"}, podNames(list)) + }) + + t.Run("cluster-scoped list, no namespace matches: all items removed", func(t *testing.T) { + fc := makeFilterClient(fake.NewClientBuilder().WithObjects(pod("a", "ns-1"), pod("b", "ns-2")).Build(), sel) // neither seeded + list := &corev1.PodList{} + require.NoError(t, fc.List(ctx, list)) + assert.Empty(t, list.Items) + }) + + t.Run("cluster-scoped list, mixed namespaces: only matching items kept", func(t *testing.T) { + fc := makeFilterClient(fake.NewClientBuilder().WithObjects(pod("a", "prod-ns"), pod("b", "dev-ns"), pod("c", "prod-ns")).Build(), sel, "prod-ns") + list := &corev1.PodList{} + require.NoError(t, fc.List(ctx, list)) + assert.ElementsMatch(t, []string{"a", "c"}, podNames(list)) + }) + + t.Run("cluster-scoped list, empty result: stays empty", func(t *testing.T) { + fc := makeFilterClient(fake.NewClientBuilder().Build(), sel, "prod-ns") + list := &corev1.PodList{} + require.NoError(t, fc.List(ctx, list)) + assert.Empty(t, list.Items) + }) + + t.Run("operator namespace always passes filter", func(t *testing.T) { + fc := makeFilterClient(fake.NewClientBuilder().WithObjects(pod("op", testOperatorNS), pod("b", "dev-ns")).Build(), sel) // no extra namespaces seeded + list := &corev1.PodList{} + require.NoError(t, fc.List(ctx, list)) + assert.ElementsMatch(t, []string{"op"}, podNames(list)) + }) + + t.Run("empty namespace (cluster-scoped resource) always passes filter", func(t *testing.T) { + fc := makeFilterClient(fake.NewClientBuilder().WithObjects(pod("cluster-res", ""), pod("b", "dev-ns")).Build(), sel) // no extra namespaces seeded + list := &corev1.PodList{} + require.NoError(t, fc.List(ctx, list)) + assert.ElementsMatch(t, []string{"cluster-res"}, podNames(list)) + }) +} + +func TestFilterClientGet(t *testing.T) { + sel := mustSelector(t, map[string]string{"env": "prod"}) + ctx := context.Background() + + t.Run("delegate error is propagated without filtering", func(t *testing.T) { + fc := NewFilterClient(fakeClientGetErr(errors.New("api server unavailable")), NewNamespaceMatcher(sel, testOperatorNS)) + require.Error(t, fc.Get(ctx, client.ObjectKey{Name: "my-pod", Namespace: "prod-ns"}, &corev1.Pod{})) + }) + + t.Run("nil notifier: object returned unfiltered", func(t *testing.T) { + fc := NewFilterClient(fake.NewClientBuilder().WithObjects(pod("my-pod", "dev-ns")).Build(), nil) + obj := &corev1.Pod{} + require.NoError(t, fc.Get(ctx, types.NamespacedName{Name: "my-pod", Namespace: "dev-ns"}, obj)) + assert.Equal(t, "dev-ns", obj.Namespace) + }) + + t.Run("selector disabled: object returned unfiltered", func(t *testing.T) { + fc := NewFilterClient(fake.NewClientBuilder().WithObjects(pod("my-pod", "dev-ns")).Build(), NewNamespaceMatcher(nil, testOperatorNS)) + obj := &corev1.Pod{} + require.NoError(t, fc.Get(ctx, types.NamespacedName{Name: "my-pod", Namespace: "dev-ns"}, obj)) + assert.Equal(t, "dev-ns", obj.Namespace) + }) + + t.Run("namespace matches: object returned", func(t *testing.T) { + fc := makeFilterClient(fake.NewClientBuilder().WithObjects(pod("my-pod", "prod-ns")).Build(), sel, "prod-ns") + obj := &corev1.Pod{} + require.NoError(t, fc.Get(ctx, types.NamespacedName{Name: "my-pod", Namespace: "prod-ns"}, obj)) + assert.Equal(t, "prod-ns", obj.Namespace) + }) + + t.Run("namespace does not match: NotFound error, object untouched, delegate not queried", func(t *testing.T) { + delegateCalled := false + delegate := fake.NewClientBuilder().WithObjects(pod("my-pod", "dev-ns")).WithInterceptorFuncs(interceptor.Funcs{ + Get: func(ctx context.Context, c client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + delegateCalled = true + return c.Get(ctx, key, obj, opts...) + }, + }).Build() + fc := makeFilterClient(delegate, sel) // dev-ns not seeded + obj := &corev1.Pod{} + err := fc.Get(ctx, types.NamespacedName{Name: "my-pod", Namespace: "dev-ns"}, obj) + require.True(t, apierrors.IsNotFound(err)) + // The object must be left exactly as passed in, like a real API-server NotFound: + // callers (and the data of the hidden resource) must not observe a populated object. + assert.Empty(t, obj.Name) + assert.Empty(t, obj.Namespace) + assert.False(t, delegateCalled) + }) + + t.Run("operator namespace always passes filter", func(t *testing.T) { + fc := makeFilterClient(fake.NewClientBuilder().WithObjects(pod("my-pod", testOperatorNS)).Build(), sel) // no extra namespaces seeded + obj := &corev1.Pod{} + require.NoError(t, fc.Get(ctx, types.NamespacedName{Name: "my-pod", Namespace: testOperatorNS}, obj)) + assert.Equal(t, testOperatorNS, obj.Namespace) + }) + + t.Run("empty namespace (cluster-scoped resource) always passes filter", func(t *testing.T) { + fc := makeFilterClient(fake.NewClientBuilder().WithObjects(pod("cluster-res", "")).Build(), sel) // no extra namespaces seeded + obj := &corev1.Pod{} + require.NoError(t, fc.Get(ctx, types.NamespacedName{Name: "cluster-res"}, obj)) + assert.Equal(t, "", obj.Namespace) + }) +} diff --git a/pkg/controller/common/nsmatch/ns.go b/pkg/controller/common/nsmatch/ns.go new file mode 100644 index 00000000000..be6bdcac2b6 --- /dev/null +++ b/pkg/controller/common/nsmatch/ns.go @@ -0,0 +1,318 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. + +package nsmatch + +import ( + "context" + "errors" + "sync" + "time" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/labels" + "sigs.k8s.io/controller-runtime/pkg/event" + + "github.com/go-logr/logr" + + ulog "github.com/elastic/cloud-on-k8s/v3/pkg/utils/log" +) + +// subscriberBufferSize is the buffer depth for each subscriber channel. A buffer this size +// lets the broadcaster proceed without blocking during bursts of namespace events. +const subscriberBufferSize = 32 + +const subscriberLogBackpressureDuration = 3 * time.Second + +// NamespaceMatcher evaluates a label selector against namespace labels, +// tracks each namespace's match state, and broadcasts to subscribers whenever +// that state flips. +// +// When the selector is nil (disabled) the notifier is a no-op: Matches always +// returns true and Broadcast is a no-op. This preserves legacy / static- +// resolution behaviour without any code changes in callers. +// +// Two namespaces bypass selector evaluation in ObserveNamespace: the empty +// string (cluster-scoped events) and the operator's own namespace — both +// always return true so the operator can reconcile its own resources +// regardless of the configured selector. +// +// Matches returns false for any namespace whose state has not yet been +// recorded by ObserveNamespace. The namespace flip-state controller seeds +// all existing namespaces at startup and re-enqueues +// CRs whenever a namespace's match state changes. Seeding runs concurrently +// with the controllers (the manager does not order runnables), so events +// dropped while a namespace is not yet seeded are backfilled by the broadcast +// its seeding emits. +// +// The match-state map is maintained on every operator replica (the namespace +// controller and its seeder do not require leader election) so that consumers +// that run on non-leaders — the webhook server and the namespace-filtering +// client — see the same state as the leader. Broadcasting to subscribers, in +// contrast, only happens on the elected leader; see Broadcast and SetElected. +type NamespaceMatcher struct { + selector labels.Selector + alwaysManagedNamespaces map[string]struct{} // namespaces excluded from label-selector evaluation. + subs []chan event.TypedGenericEvent[*corev1.Namespace] + matchedNamespacesMutex sync.Mutex + matchedNamespaces map[string]struct{} // namespace name -> present if namespace matches to the selector + subscriberLogBackpressureDuration time.Duration + subscriberBufferSize int + elected <-chan struct{} // closed once this replica is elected leader; nil means always elected. + initialSeeding chan struct{} // closed by InitialSeed once all pre-existing namespaces have been observed. + initialSeedingOnce sync.Once +} + +// NewNamespaceMatcher returns a NamespaceMatcher. When sel is nil it acts as +// a no-op (Matches always returns true). Both the empty string (cluster-scoped +// resources) and operatorNS are pre-seeded in the short-circuit set so that +// cluster-scoped events and the operator's own namespace always match regardless +// of the configured selector. +func NewNamespaceMatcher(sel labels.Selector, operatorNS string) *NamespaceMatcher { + return &NamespaceMatcher{ + selector: sel, + alwaysManagedNamespaces: map[string]struct{}{ + "": {}, + operatorNS: {}, + }, + matchedNamespaces: map[string]struct{}{}, + subscriberLogBackpressureDuration: subscriberLogBackpressureDuration, + subscriberBufferSize: subscriberBufferSize, + initialSeeding: make(chan struct{}), + } +} + +// SetElected provides the manager's election signal (manager.Elected()), a +// channel that is closed once this replica becomes leader (or immediately when +// leader election is disabled). The match-state map is maintained on every +// replica, but the controllers subscribed via Subscribe are leader-election +// runnables: on a non-leader they never consume from their channels, so +// Broadcast must not send. When set, Broadcast drops events until the channel +// is closed. When never set (tests), Broadcast always sends. +func (m *NamespaceMatcher) SetElected(elected <-chan struct{}) { + m.elected = elected +} + +// isElected reports whether this replica may broadcast to subscribers. True +// when no election signal was provided or when the election channel is closed. +// +// The select is deterministic: default only runs when no other case can +// proceed, and a receive from a closed channel always proceeds, so once +// elected is closed this never returns false. A select racing with the close +// itself may still return false; that at worst drops one broadcast at the +// moment of election, which the subscribers' initial sync covers anyway. +func (m *NamespaceMatcher) isElected() bool { + if m.elected == nil { + return true + } + select { + case <-m.elected: + return true + default: + return false + } +} + +// SelectorEnabled reports whether the matcher is actively filtering. +// Safe to call on a nil receiver; returns false in that case. +func (m *NamespaceMatcher) SelectorEnabled() bool { + return m != nil && m.selector != nil +} + +// Matches returns the last recorded match state for ns. It returns false for +// any namespace not yet observed by ObserveNamespace. When the selector is +// disabled, always returns true. +func (m *NamespaceMatcher) Matches(ns string) bool { + if !m.SelectorEnabled() { + return true + } + + if _, ok := m.alwaysManagedNamespaces[ns]; ok { + return true + } + + m.matchedNamespacesMutex.Lock() + defer m.matchedNamespacesMutex.Unlock() + _, match := m.matchedNamespaces[ns] + return match +} + +func (m *NamespaceMatcher) MatchingNamespaces() []string { + m.matchedNamespacesMutex.Lock() + defer m.matchedNamespacesMutex.Unlock() + + names := make([]string, 0, len(m.matchedNamespaces)+len(m.alwaysManagedNamespaces)) + for ns := range m.matchedNamespaces { + names = append(names, ns) + } + for ns := range m.alwaysManagedNamespaces { + if ns == "" { + continue + } + names = append(names, ns) + } + + return names +} + +// ObserveNamespace evaluates the selector against ns's current labels, records +// the result, and returns both the current and previous match states. Short- +// circuited namespaces (empty string and the operator's namespace) always +// return (true, true) without updating the internal state map. When the +// selector is disabled, always returns (true, true). +func (m *NamespaceMatcher) ObserveNamespace(ns *corev1.Namespace) (isMatching bool, wasMatching bool) { + if !m.SelectorEnabled() { + return true, true + } + + if _, ok := m.alwaysManagedNamespaces[ns.Name]; ok { + return true, true + } + + isMatching = m.selector.Matches(labels.Set(ns.Labels)) + + wasMatching = m.Swap(ns.Name, isMatching) + return +} + +// Subscribe returns a receive-only channel that receives one event each time +// Broadcast is called. Intended to be passed to a controller's Watch source. +func (m *NamespaceMatcher) Subscribe() <-chan event.TypedGenericEvent[*corev1.Namespace] { + ch := make(chan event.TypedGenericEvent[*corev1.Namespace], m.subscriberBufferSize) + // No mutex needed: all controllers call Subscribe sequentially during + // manager initialization, before mgr.Start() launches any goroutines. + m.subs = append(m.subs, ch) + return ch +} + +// Broadcast sends the namespace to every subscriber, blocking on each send +// until it succeeds or ctx is done. A blocked send is logged periodically +// (every subscriberLogBackpressureDuration) so persistent backpressure is +// visible. On context cancellation, Broadcast stops waiting on the current +// subscriber, records ctx's error for it, and moves on to the rest. +// +// On a replica that has not (yet) been elected leader, Broadcast is a no-op: +// subscriber controllers only run on the leader, so nothing consumes from the +// channels and a blocking send would never complete. Dropping is safe because +// when this replica is later elected, every subscriber controller starts with +// a full initial sync of its own CRs, whose watch predicates consult the +// match-state map — maintained on every replica — so flips dropped here are +// re-evaluated then. +func (m *NamespaceMatcher) Broadcast(ctx context.Context, ns *corev1.Namespace) error { + if !m.SelectorEnabled() { + return nil + } + + if !m.isElected() { + // Dropping the event here loses no information: the match state was + // recorded in the map before Broadcast was called, and the map is what + // carries the state across the election. When this replica becomes + // leader, the elected channel is closed before the subscriber + // controllers start, so their initial sync replays every CR against + // the already-warm map — re-enqueue triggers dropped here are subsumed + // by that replay. Broadcasts are only needed for flips that happen + // after the initial sync, and by then this branch is no longer taken. + return nil + } + + log := ulog.FromContext(ctx) + + // No mutex needed: subs is written only during initialization (Subscribe) + // and is immutable by the time Broadcast is first called. Concurrent sends + // to the same channel are safe because channels handle their own synchronization. + ev := event.TypedGenericEvent[*corev1.Namespace]{Object: ns} + errs := make([]error, len(m.subs)) + for i, ch := range m.subs { + errs[i] = sendAndLogBackpressure(ctx, log, ch, m.subscriberLogBackpressureDuration, ev) + } + return errors.Join(errs...) +} + +// ObserveAndBroadcast calls ObserveNamespace and broadcasts ns to all +// subscribers if the match state flipped. Returns whether the state changed +// and the current match result. When the selector is disabled, ObserveNamespace +// returns (true, true), so stateChanged is always false and no broadcast occurs. +func (m *NamespaceMatcher) ObserveAndBroadcast(ctx context.Context, ns *corev1.Namespace) (stateChanged, isMatching bool, err error) { + var wasWatching bool + isMatching, wasWatching = m.ObserveNamespace(ns) + stateChanged = isMatching != wasWatching + if stateChanged { + err = m.Broadcast(ctx, ns) + } + return +} + +// InitialSeed observes and broadcasts each namespace in seed, then marks +// initial seeding as complete, releasing every WaitForInitialSeeding caller. +// Stops early, without marking seeding complete, if ctx is done before all +// namespaces have been processed. Safe to call more than once. +func (m *NamespaceMatcher) InitialSeed(ctx context.Context, log logr.Logger, seed []corev1.Namespace) { + for _, ns := range seed { + if ctx.Err() != nil { + return + } + if _, _, err := m.ObserveAndBroadcast(ctx, &ns); err != nil { + log.Error(err, "failed to seed namespace match state", "namespace", ns.Name) + } + } + + m.initialSeedingOnce.Do(func() { + close(m.initialSeeding) + }) +} + +// WaitForInitialSeeding blocks until InitialSeed has finished or ctx is done, +// in which case it returns ctx's error. When the selector is disabled no +// seeding takes place, so it returns immediately. +func (m *NamespaceMatcher) WaitForInitialSeeding(ctx context.Context) error { + if !m.SelectorEnabled() { + return nil + } + select { + case <-m.initialSeeding: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +// Swap records isMatching for ns and returns the previously recorded value +// (wasMatching). It is intended for internal use by ObserveNamespace and for +// external test usage; other callers should use ObserveNamespace and +// ForgetNamespace instead. +func (m *NamespaceMatcher) Swap(ns string, isMatching bool) (wasMatching bool) { + m.matchedNamespacesMutex.Lock() + defer m.matchedNamespacesMutex.Unlock() + _, wasMatching = m.matchedNamespaces[ns] + if isMatching { + m.matchedNamespaces[ns] = struct{}{} + } else { + delete(m.matchedNamespaces, ns) + } + return +} + +// ForgetNamespace clears any recorded match state for ns without broadcasting. +// Intended for use when ns has been deleted: its resources are being cleaned +// up by their own controllers, so there is nothing for subscribers to react to. +func (m *NamespaceMatcher) ForgetNamespace(ns string) { + _ = m.Swap(ns, false) +} + +func sendAndLogBackpressure[chType any](ctx context.Context, log logr.Logger, ch chan<- chType, logDuration time.Duration, item chType) error { + ticker := time.NewTicker(logDuration) + defer ticker.Stop() + waited := time.Duration(0) + for { + select { + case ch <- item: + return nil + case <-ticker.C: + waited += logDuration + log.Info("subscriber channel send is still blocked, possible backpressure", "waited", waited.String()) + case <-ctx.Done(): + return ctx.Err() + } + } +} diff --git a/pkg/controller/common/nsmatch/ns_test.go b/pkg/controller/common/nsmatch/ns_test.go new file mode 100644 index 00000000000..2f8115537b6 --- /dev/null +++ b/pkg/controller/common/nsmatch/ns_test.go @@ -0,0 +1,654 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. + +package nsmatch + +import ( + "context" + "strings" + "sync" + "testing" + "time" + + "github.com/go-logr/logr" + "github.com/go-logr/logr/funcr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + + ulog "github.com/elastic/cloud-on-k8s/v3/pkg/utils/log" +) + +const testOperatorNS = "elastic-system" + +func mustSelector(t *testing.T, matchLabels map[string]string) labels.Selector { + t.Helper() + ls := metav1.LabelSelector{MatchLabels: matchLabels} + sel, err := metav1.LabelSelectorAsSelector(&ls) + if err != nil { + t.Fatalf("LabelSelectorAsSelector error %s", err.Error()) + } + return sel +} + +func namespace(name string, lbls map[string]string) *corev1.Namespace { + return &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: name, Labels: lbls}, + } +} + +func TestMatcher(t *testing.T) { + t.Run("SelectorEnabled", func(t *testing.T) { + assert.False(t, (*NamespaceMatcher)(nil).SelectorEnabled(), "nil MatchNotifier is disabled") + assert.False(t, NewNamespaceMatcher(nil, testOperatorNS).SelectorEnabled(), "nil selector is disabled") + assert.True(t, NewNamespaceMatcher(mustSelector(t, map[string]string{"env": "prod"}), testOperatorNS).SelectorEnabled(), "non-nil selector is enabled") + }) + + t.Run("Matches disabled always returns true", func(t *testing.T) { + m := NewNamespaceMatcher(nil, testOperatorNS) + assert.True(t, m.Matches("any-namespace"), "disabled matcher always matches") + assert.True(t, m.Matches(""), "disabled matcher matches empty namespace") + }) + + t.Run("Matches unknown namespace returns false", func(t *testing.T) { + sel := mustSelector(t, map[string]string{"env": "prod"}) + m := NewNamespaceMatcher(sel, testOperatorNS) + assert.False(t, m.Matches("unknown"), "namespace not yet in states returns false") + }) + + t.Run("Matches returns true when state is true", func(t *testing.T) { + sel := mustSelector(t, map[string]string{"env": "prod"}) + m := NewNamespaceMatcher(sel, testOperatorNS) + m.Swap("prod-ns", true) + assert.True(t, m.Matches("prod-ns")) + }) + + t.Run("Matches returns false when state is false", func(t *testing.T) { + sel := mustSelector(t, map[string]string{"env": "prod"}) + m := NewNamespaceMatcher(sel, testOperatorNS) + m.Swap("dev-ns", false) + assert.False(t, m.Matches("dev-ns")) + }) + + t.Run("Matches returns true when state for short-circuit", func(t *testing.T) { + sel := mustSelector(t, map[string]string{"env": "prod"}) + m := NewNamespaceMatcher(sel, testOperatorNS) + assert.True(t, m.Matches(testOperatorNS)) + }) + + t.Run("Matches returns true for empty string short-circuit when selector is enabled", func(t *testing.T) { + sel := mustSelector(t, map[string]string{"env": "prod"}) + m := NewNamespaceMatcher(sel, testOperatorNS) + assert.True(t, m.Matches(""), "empty namespace is always short-circuited") + }) +} + +func TestObserveNamespace(t *testing.T) { + t.Run("disabled returns true true for any namespace", func(t *testing.T) { + m := NewNamespaceMatcher(nil, testOperatorNS) + isMatching, wasMatching := m.ObserveNamespace(namespace("any", map[string]string{"env": "dev"})) + assert.True(t, isMatching) + assert.True(t, wasMatching) + }) + + t.Run("short-circuit empty namespace returns true true without updating state", func(t *testing.T) { + sel := mustSelector(t, map[string]string{"env": "prod"}) + m := NewNamespaceMatcher(sel, testOperatorNS) + isMatching, wasMatching := m.ObserveNamespace(namespace("", nil)) + assert.True(t, isMatching) + assert.True(t, wasMatching) + // The short-circuit must not pollute the states map. + _, inStates := m.matchedNamespaces[""] + assert.False(t, inStates, "empty namespace must not be written to states") + }) + + t.Run("short-circuit operator namespace returns true true without updating state", func(t *testing.T) { + sel := mustSelector(t, map[string]string{"env": "prod"}) + m := NewNamespaceMatcher(sel, testOperatorNS) + isMatching, wasMatching := m.ObserveNamespace(namespace(testOperatorNS, nil)) + assert.True(t, isMatching) + assert.True(t, wasMatching) + _, inStates := m.matchedNamespaces[testOperatorNS] + assert.False(t, inStates, "operator namespace must not be written to states") + }) + + t.Run("matching namespace updates state and returns correct values", func(t *testing.T) { + sel := mustSelector(t, map[string]string{"env": "prod"}) + m := NewNamespaceMatcher(sel, testOperatorNS) + isMatching, wasMatching := m.ObserveNamespace(namespace("prod-ns", map[string]string{"env": "prod"})) + assert.True(t, isMatching) + assert.False(t, wasMatching, "not previously known") + assert.True(t, m.Matches("prod-ns"), "state updated to true") + }) + + t.Run("non-matching namespace updates state and returns correct values", func(t *testing.T) { + sel := mustSelector(t, map[string]string{"env": "prod"}) + m := NewNamespaceMatcher(sel, testOperatorNS) + isMatching, wasMatching := m.ObserveNamespace(namespace("dev-ns", map[string]string{"env": "dev"})) + assert.False(t, isMatching) + assert.False(t, wasMatching, "not previously known") + assert.False(t, m.Matches("dev-ns"), "state updated to false") + }) + + t.Run("namespace transitions from matching to non-matching", func(t *testing.T) { + sel := mustSelector(t, map[string]string{"env": "prod"}) + m := NewNamespaceMatcher(sel, testOperatorNS) + m.ObserveNamespace(namespace("ns", map[string]string{"env": "prod"})) + + isMatching, wasMatching := m.ObserveNamespace(namespace("ns", map[string]string{"env": "staging"})) + assert.False(t, isMatching) + assert.True(t, wasMatching) + }) + + t.Run("namespace transitions from non-matching to matching", func(t *testing.T) { + sel := mustSelector(t, map[string]string{"env": "prod"}) + m := NewNamespaceMatcher(sel, testOperatorNS) + m.ObserveNamespace(namespace("ns", map[string]string{"env": "dev"})) + + isMatching, wasMatching := m.ObserveNamespace(namespace("ns", map[string]string{"env": "prod"})) + assert.True(t, isMatching) + assert.False(t, wasMatching) + }) +} + +func TestNotifier(t *testing.T) { + sel := mustSelector(t, map[string]string{"env": "prod"}) + + t.Run("no subscribers", func(t *testing.T) { + n := NewNamespaceMatcher(sel, "") + // Broadcast with no subscribers must not panic. + err := n.Broadcast(t.Context(), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "ns"}}) + require.NoError(t, err) + }) + + t.Run("disabled selector no-ops broadcast", func(t *testing.T) { + n := NewNamespaceMatcher(nil, "") + ch := n.Subscribe() + err := n.Broadcast(t.Context(), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "ns"}}) + require.NoError(t, err) + assert.Len(t, ch, 0, "broadcast is a no-op when selector is disabled") + }) + + t.Run("single subscriber receives broadcast", func(t *testing.T) { + n := NewNamespaceMatcher(sel, "") + ch := n.Subscribe() + + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "ns-a"}} + require.NoError(t, n.Broadcast(t.Context(), ns)) + + require.Len(t, ch, 1) + assert.Equal(t, ns, (<-ch).Object) + }) + + t.Run("multiple subscribers each receive broadcast", func(t *testing.T) { + n := NewNamespaceMatcher(sel, "") + ch1 := n.Subscribe() + ch2 := n.Subscribe() + + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "ns-b"}} + require.NoError(t, n.Broadcast(t.Context(), ns)) + + require.Len(t, ch1, 1) + require.Len(t, ch2, 1) + assert.Equal(t, ns, (<-ch1).Object) + assert.Equal(t, ns, (<-ch2).Object) + }) + + t.Run("multiple events delivered in order", func(t *testing.T) { + n := NewNamespaceMatcher(sel, "") + ch := n.Subscribe() + + ns1 := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "ns-1"}} + ns2 := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "ns-2"}} + require.NoError(t, n.Broadcast(t.Context(), ns1)) + require.NoError(t, n.Broadcast(t.Context(), ns2)) + + require.Len(t, ch, 2) + assert.Equal(t, ns1, (<-ch).Object) + assert.Equal(t, ns2, (<-ch).Object) + }) + + t.Run("late subscriber does not receive earlier broadcasts", func(t *testing.T) { + n := NewNamespaceMatcher(sel, "") + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "ns-c"}} + require.NoError(t, n.Broadcast(t.Context(), ns)) // no subscribers yet + + ch := n.Subscribe() + assert.Len(t, ch, 0, "late subscriber must not receive events broadcast before it subscribed") + }) + + t.Run("cancelled context does not block on full subscriber channel", func(t *testing.T) { + n := NewNamespaceMatcher(sel, "") + n.subscriberBufferSize = 0 + _ = n.Subscribe() + // Access the underlying bidirectional channel. + internal := n.subs[0] + + done := make(chan struct{}) + go func() { + cancelledCtx, cancel := context.WithCancel(t.Context()) + cancel() + err := n.Broadcast(cancelledCtx, namespace("ns", nil)) + require.Error(t, err) + close(done) + }() + + select { + case <-done: + // expected: Broadcast returned without blocking + case <-time.After(100 * time.Millisecond): + t.Fatal("Broadcast blocked despite cancelled context") + } + assert.Len(t, internal, n.subscriberBufferSize, "no new event must be added to an already-full channel") + }) + + t.Run("full subscriber channel blocks until context is cancelled", func(t *testing.T) { + n := NewNamespaceMatcher(sel, "") + n.subscriberBufferSize = 0 + _ = n.Subscribe() // unbuffered, no receiver: any send blocks indefinitely + + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan struct{}) + var broadcastErr error + go func() { + broadcastErr = n.Broadcast(ctx, namespace("ns", nil)) + close(done) + }() + + select { + case <-done: + t.Fatal("Broadcast returned before context was cancelled; a blocked send must have no deadline of its own") + case <-time.After(100 * time.Millisecond): + // expected: still blocked, there is no send timeout any more + } + + cancel() + + select { + case <-done: + assert.ErrorIs(t, broadcastErr, context.Canceled) + case <-time.After(100 * time.Millisecond): + t.Fatal("Broadcast did not return after context cancellation") + } + }) + + t.Run("cancelled context", func(t *testing.T) { + n := NewNamespaceMatcher(sel, "") + // Make channels non-buffered so that ch <- item cannot win the select race, + // guaranteeing context.Canceled is always returned for each subscriber. + n.subscriberBufferSize = 0 + _ = n.Subscribe() + _ = n.Subscribe() + + cancelledCtx, cancel := context.WithCancel(t.Context()) + cancel() + + done := make(chan struct{}) + var broadcastErr error + go func() { + broadcastErr = n.Broadcast(cancelledCtx, namespace("ns", nil)) + close(done) + }() + + select { + case <-done: + case <-time.After(100 * time.Millisecond): + t.Fatal("Broadcast blocked despite cancelled context") + } + + require.Error(t, broadcastErr) + var joinedErrs interface{ Unwrap() []error } + require.ErrorAs(t, broadcastErr, &joinedErrs) + errs := joinedErrs.Unwrap() + require.Len(t, errs, 2, "expected one error per subscriber") + assert.ErrorIs(t, errs[0], context.Canceled) + assert.ErrorIs(t, errs[1], context.Canceled) + }) +} + +// TestBroadcastLogsBackpressure verifies that a subscriber send blocked past +// subscriberLogBackpressureDuration is actually logged, and logged again on +// each subsequent tick, not just once. +func TestBroadcastLogsBackpressure(t *testing.T) { + sel := mustSelector(t, map[string]string{"env": "prod"}) + n := NewNamespaceMatcher(sel, "") + n.subscriberBufferSize = 0 + n.subscriberLogBackpressureDuration = 10 * time.Millisecond + _ = n.Subscribe() // unbuffered, no receiver: the send blocks, triggering backpressure logs + + var mu sync.Mutex + var lines []string + testLogger := funcr.New(func(_, args string) { + mu.Lock() + defer mu.Unlock() + lines = append(lines, args) + }, funcr.Options{}) + + ctx, cancel := context.WithCancel(ulog.AddToContext(t.Context(), testLogger)) + defer cancel() + + done := make(chan struct{}) + go func() { + _ = n.Broadcast(ctx, namespace("ns", nil)) + close(done) + }() + + countBackpressureLines := func() int { + mu.Lock() + defer mu.Unlock() + count := 0 + for _, l := range lines { + if strings.Contains(l, "backpressure") { + count++ + } + } + return count + } + + require.Eventually(t, func() bool { + return countBackpressureLines() >= 1 + }, time.Second, 5*time.Millisecond, "expected at least one backpressure log line while the send is blocked") + + require.Eventually(t, func() bool { + return countBackpressureLines() >= 2 + }, time.Second, 5*time.Millisecond, "expected the backpressure log line to repeat while still blocked") + + cancel() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("Broadcast did not return after context cancellation") + } +} + +func TestBroadcastElectionGating(t *testing.T) { + sel := mustSelector(t, map[string]string{"env": "prod"}) + + t.Run("no election signal set: broadcast delivers", func(t *testing.T) { + n := NewNamespaceMatcher(sel, testOperatorNS) + sub := n.Subscribe() + require.NoError(t, n.Broadcast(t.Context(), namespace("ns", nil))) + require.Len(t, sub, 1) + }) + + t.Run("not elected: broadcast is a no-op", func(t *testing.T) { + n := NewNamespaceMatcher(sel, testOperatorNS) + n.SetElected(make(chan struct{})) // never closed: this replica is never elected + sub := n.Subscribe() + require.NoError(t, n.Broadcast(t.Context(), namespace("ns", nil))) + require.Empty(t, sub, "no event must be delivered on a non-elected replica") + }) + + t.Run("elected: broadcast delivers", func(t *testing.T) { + n := NewNamespaceMatcher(sel, testOperatorNS) + elected := make(chan struct{}) + n.SetElected(elected) + sub := n.Subscribe() + + require.NoError(t, n.Broadcast(t.Context(), namespace("dropped", nil))) + require.Empty(t, sub) + + close(elected) + require.NoError(t, n.Broadcast(t.Context(), namespace("delivered", nil))) + require.Len(t, sub, 1) + ev := <-sub + require.Equal(t, "delivered", ev.Object.GetName()) + }) +} + +func TestObserveAndBroadcast(t *testing.T) { + sel := mustSelector(t, map[string]string{"env": "prod"}) + + matching := namespace("prod-ns", map[string]string{"env": "prod"}) + nonMatching := namespace("dev-ns", map[string]string{"env": "dev"}) + + t.Run("disabled selector: no state change, no broadcast, reports matching", func(t *testing.T) { + m := NewNamespaceMatcher(nil, testOperatorNS) + ch := m.Subscribe() + stateChanged, isMatching, _ := m.ObserveAndBroadcast(t.Context(), matching) + assert.False(t, stateChanged) + assert.True(t, isMatching) + assert.Len(t, ch, 0) + }) + + t.Run("short-circuit namespace: no state change, no broadcast, reports matching", func(t *testing.T) { + m := NewNamespaceMatcher(sel, testOperatorNS) + ch := m.Subscribe() + stateChanged, isMatching, _ := m.ObserveAndBroadcast(t.Context(), namespace(testOperatorNS, nil)) + assert.False(t, stateChanged) + assert.True(t, isMatching) + assert.Len(t, ch, 0) + }) + + t.Run("first observe, namespace matches: state changed, broadcast sent", func(t *testing.T) { + m := NewNamespaceMatcher(sel, testOperatorNS) + ch := m.Subscribe() + stateChanged, isMatching, _ := m.ObserveAndBroadcast(t.Context(), matching) + assert.True(t, stateChanged) + assert.True(t, isMatching) + require.Len(t, ch, 1) + assert.Equal(t, matching, (<-ch).Object) + }) + + t.Run("first observe, namespace does not match: no state change, no broadcast", func(t *testing.T) { + m := NewNamespaceMatcher(sel, testOperatorNS) + ch := m.Subscribe() + stateChanged, isMatching, _ := m.ObserveAndBroadcast(t.Context(), nonMatching) + assert.False(t, stateChanged) + assert.False(t, isMatching) + assert.Len(t, ch, 0) + }) + + t.Run("state unchanged: namespace still matches, no broadcast", func(t *testing.T) { + m := NewNamespaceMatcher(sel, testOperatorNS) + ch := m.Subscribe() + m.Swap(matching.Name, true) + stateChanged, isMatching, _ := m.ObserveAndBroadcast(t.Context(), matching) + assert.False(t, stateChanged) + assert.True(t, isMatching) + assert.Len(t, ch, 0) + }) + + t.Run("state unchanged: namespace still does not match, no broadcast", func(t *testing.T) { + m := NewNamespaceMatcher(sel, testOperatorNS) + ch := m.Subscribe() + m.Swap(nonMatching.Name, false) + stateChanged, isMatching, _ := m.ObserveAndBroadcast(t.Context(), nonMatching) + assert.False(t, stateChanged) + assert.False(t, isMatching) + assert.Len(t, ch, 0) + }) + + t.Run("state change matching -> non-matching: state changed, broadcast sent", func(t *testing.T) { + m := NewNamespaceMatcher(sel, testOperatorNS) + ch := m.Subscribe() + m.Swap(nonMatching.Name, true) // was matching + stateChanged, isMatching, _ := m.ObserveAndBroadcast(t.Context(), nonMatching) + assert.True(t, stateChanged) + assert.False(t, isMatching) + require.Len(t, ch, 1) + assert.Equal(t, nonMatching, (<-ch).Object) + }) + + t.Run("state change non-matching -> matching: state changed, broadcast sent", func(t *testing.T) { + m := NewNamespaceMatcher(sel, testOperatorNS) + ch := m.Subscribe() + m.Swap(matching.Name, false) // was non-matching + stateChanged, isMatching, _ := m.ObserveAndBroadcast(t.Context(), matching) + assert.True(t, stateChanged) + assert.True(t, isMatching) + require.Len(t, ch, 1) + assert.Equal(t, matching, (<-ch).Object) + }) + + t.Run("empty namespace short-circuit: no state change, no broadcast, reports matching", func(t *testing.T) { + m := NewNamespaceMatcher(sel, testOperatorNS) + ch := m.Subscribe() + stateChanged, isMatching, _ := m.ObserveAndBroadcast(t.Context(), namespace("", nil)) + assert.False(t, stateChanged) + assert.True(t, isMatching) + assert.Len(t, ch, 0) + }) +} + +func TestSwap(t *testing.T) { + sel := mustSelector(t, map[string]string{"env": "prod"}) + + t.Run("first swap true: wasMatching false, namespace added to states", func(t *testing.T) { + m := NewNamespaceMatcher(sel, testOperatorNS) + wasMatching := m.Swap("ns", true) + assert.False(t, wasMatching) + assert.True(t, m.Matches("ns")) + }) + + t.Run("second swap true: wasMatching true, namespace stays in states", func(t *testing.T) { + m := NewNamespaceMatcher(sel, testOperatorNS) + m.Swap("ns", true) + wasMatching := m.Swap("ns", true) + assert.True(t, wasMatching) + assert.True(t, m.Matches("ns")) + }) + + t.Run("swap false when was matching: wasMatching true, namespace removed from states", func(t *testing.T) { + m := NewNamespaceMatcher(sel, testOperatorNS) + m.Swap("ns", true) + wasMatching := m.Swap("ns", false) + assert.True(t, wasMatching) + assert.False(t, m.Matches("ns")) + }) + + t.Run("swap false when was not tracked: wasMatching false, namespace absent from states", func(t *testing.T) { + m := NewNamespaceMatcher(sel, testOperatorNS) + wasMatching := m.Swap("ns", false) + assert.False(t, wasMatching) + assert.False(t, m.Matches("ns")) + }) +} + +func TestWaitForInitialSeeding(t *testing.T) { + sel := mustSelector(t, map[string]string{"managed": "true"}) + + t.Run("returns once InitialSeed finishes", func(t *testing.T) { + m := NewNamespaceMatcher(sel, testOperatorNS) + done := make(chan error, 1) + go func() { done <- m.WaitForInitialSeeding(t.Context()) }() + select { + case <-done: + t.Fatal("WaitForInitialSeeding returned before InitialSeed finished") + case <-time.After(20 * time.Millisecond): + } + m.InitialSeed(t.Context(), logr.Discard(), nil) + select { + case err := <-done: + require.NoError(t, err) + case <-time.After(time.Second): + t.Fatal("WaitForInitialSeeding did not return after InitialSeed finished") + } + }) + + t.Run("returns ctx error when context is cancelled", func(t *testing.T) { + m := NewNamespaceMatcher(sel, testOperatorNS) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + require.ErrorIs(t, m.WaitForInitialSeeding(ctx), context.Canceled) + }) + + t.Run("returns immediately when selector is disabled", func(t *testing.T) { + m := NewNamespaceMatcher(nil, testOperatorNS) + require.NoError(t, m.WaitForInitialSeeding(t.Context())) + }) + + t.Run("InitialSeed with no namespaces is idempotent", func(t *testing.T) { + m := NewNamespaceMatcher(sel, testOperatorNS) + m.InitialSeed(t.Context(), logr.Discard(), nil) + m.InitialSeed(t.Context(), logr.Discard(), nil) + require.NoError(t, m.WaitForInitialSeeding(t.Context())) + }) +} + +func TestInitialSeed(t *testing.T) { + sel := mustSelector(t, map[string]string{"managed": "true"}) + + t.Run("observes and broadcasts each namespace, then closes the seeding gate", func(t *testing.T) { + m := NewNamespaceMatcher(sel, testOperatorNS) + sub := m.Subscribe() + seed := []corev1.Namespace{ + *namespace("matching", map[string]string{"managed": "true"}), + *namespace("non-matching", nil), + } + + m.InitialSeed(t.Context(), logr.Discard(), seed) + + assert.True(t, m.Matches("matching")) + assert.False(t, m.Matches("non-matching")) + require.Len(t, sub, 1, "expected a broadcast only for the namespace whose match state changed") + require.NoError(t, m.WaitForInitialSeeding(t.Context())) + }) + + t.Run("stops seeding without closing the gate once the context is done", func(t *testing.T) { + m := NewNamespaceMatcher(sel, testOperatorNS) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + m.InitialSeed(ctx, logr.Discard(), []corev1.Namespace{ + *namespace("ns", map[string]string{"managed": "true"}), + }) + + assert.False(t, m.Matches("ns"), "namespace should not have been observed once the context was already done") + + waitCtx, waitCancel := context.WithTimeout(t.Context(), 20*time.Millisecond) + defer waitCancel() + require.ErrorIs(t, m.WaitForInitialSeeding(waitCtx), context.DeadlineExceeded, "seeding gate should not be closed when seeding was aborted early") + }) + + t.Run("logs an error but keeps seeding when ObserveAndBroadcast fails for a namespace", func(t *testing.T) { + m := NewNamespaceMatcher(sel, testOperatorNS) + m.subscriberBufferSize = 0 + m.Subscribe() // unbuffered, no receiver: forces Broadcast to observe ctx cancellation instead of completing + + var mu sync.Mutex + var lines []string + testLogger := funcr.New(func(_, args string) { + mu.Lock() + defer mu.Unlock() + lines = append(lines, args) + }, funcr.Options{}) + + ctx, cancel := context.WithCancel(t.Context()) + seed := []corev1.Namespace{ + *namespace("blocked", map[string]string{"managed": "true"}), + *namespace("never-reached", map[string]string{"managed": "true"}), + } + + done := make(chan struct{}) + go func() { + m.InitialSeed(ctx, testLogger, seed) + close(done) + }() + + time.Sleep(20 * time.Millisecond) // let the broadcast for "blocked" start blocking on the unbuffered subscriber channel + cancel() + + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("InitialSeed did not return after context cancellation") + } + + assert.True(t, m.Matches("blocked"), "match state is recorded before broadcast is attempted, regardless of broadcast outcome") + assert.False(t, m.Matches("never-reached"), "seeding should have stopped once ctx was done, before reaching the second namespace") + + mu.Lock() + defer mu.Unlock() + found := false + for _, l := range lines { + if strings.Contains(l, "failed to seed namespace match state") { + found = true + } + } + assert.True(t, found, "expected an error to be logged for the namespace whose broadcast failed") + }) +} diff --git a/pkg/controller/common/operator/flags.go b/pkg/controller/common/operator/flags.go index 32b851c7127..8a31a0bcdfa 100644 --- a/pkg/controller/common/operator/flags.go +++ b/pkg/controller/common/operator/flags.go @@ -38,6 +38,7 @@ const ( MetricsSecureFlag = "metrics-secure" MetricsCertDirFlag = "metrics-cert-dir" NamespacesFlag = "namespaces" + NamespaceSelectorFlag = "namespace-selector" OperatorNamespaceFlag = "operator-namespace" SetDefaultSecurityContextFlag = "set-default-security-context" TelemetryIntervalFlag = "telemetry-interval" diff --git a/pkg/controller/common/operator/parameters.go b/pkg/controller/common/operator/parameters.go index 83f0e59b0b2..1875c26f680 100644 --- a/pkg/controller/common/operator/parameters.go +++ b/pkg/controller/common/operator/parameters.go @@ -12,6 +12,7 @@ import ( "github.com/elastic/cloud-on-k8s/v3/pkg/about" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/certificates" + "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/nsmatch" commonpassword "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/password" esvalidation "github.com/elastic/cloud-on-k8s/v3/pkg/controller/elasticsearch/validation" "github.com/elastic/cloud-on-k8s/v3/pkg/utils/cryptutil" @@ -52,4 +53,10 @@ type Parameters struct { ValidateStorageClass bool // Tracer is a shared APM tracer instance or nil Tracer *apm.Tracer + // NamespaceMatcher combines namespace-selector matching and match state change + // notification. In dynamic mode it evaluates the configured namespaceSelector + // against Namespace labels per event and broadcasts Namespace objects to + // per-kind controllers whenever a namespace's match-state changes. Nil in + // legacy / static-resolution modes. + NamespaceMatcher *nsmatch.NamespaceMatcher } diff --git a/pkg/controller/common/watches/namespace_flip.go b/pkg/controller/common/watches/namespace_flip.go new file mode 100644 index 00000000000..b96ebf8076b --- /dev/null +++ b/pkg/controller/common/watches/namespace_flip.go @@ -0,0 +1,86 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. + +package watches + +import ( + "context" + + corev1 "k8s.io/api/core/v1" + apimeta "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/cache" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/source" +) + +// WatchNamespaceFlips registers a source.Channel watch driven by notifier. +// Whenever a namespace's match state changes, the mapper lists all objects of the +// kind produced by newList in that namespace and enqueues a reconcile request for +// each. No-ops when notifier is nil (legacy / static-namespace mode). +func WatchNamespaceFlips( + c controller.Controller, + ch cache.Cache, + notifier NamespaceNotifier, + newList func() client.ObjectList, +) error { + return WatchNamespaceFlipsMapped(c, notifier, + func(ctx context.Context, ns *corev1.Namespace) []reconcile.Request { + list := newList() + // Use the cache directly (not the FilterClient) so that resources in + // namespaces being de-scoped are still visible here. The FilterClient + // would silently drop them because the namespace no longer matches the + // selector, causing us to miss the reconcile requests needed to clean up. + if err := ch.List(ctx, list, client.InNamespace(ns.Name)); err != nil { + return nil + } + items, err := apimeta.ExtractList(list) + if err != nil { + return nil + } + reqs := make([]reconcile.Request, 0, len(items)) + for _, item := range items { + obj, ok := item.(client.Object) + if !ok { + continue + } + reqs = append(reqs, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Namespace: obj.GetNamespace(), + Name: obj.GetName(), + }, + }) + } + return reqs + }, + ) +} + +// WatchNamespaceFlipsMapped registers a source.Channel watch driven by notifier with a +// caller-provided mapper deciding which reconcile requests a namespace match-state change +// translates into. Use this instead of WatchNamespaceFlips when the objects to re-enqueue +// are not simply the ones living in the flipped namespace (e.g. associations referencing +// resources in that namespace). No-ops when notifier is nil (legacy / static-namespace mode). +func WatchNamespaceFlipsMapped( + c controller.Controller, + notifier NamespaceNotifier, + mapFn func(context.Context, *corev1.Namespace) []reconcile.Request, +) error { + if notifier == nil { + return nil + } + return c.Watch(source.Channel( + notifier.Subscribe(), + handler.TypedEnqueueRequestsFromMapFunc(mapFn), + )) +} + +type NamespaceNotifier interface { + Subscribe() <-chan event.TypedGenericEvent[*corev1.Namespace] + SelectorEnabled() bool +} diff --git a/pkg/controller/common/watches/namespaced_kind.go b/pkg/controller/common/watches/namespaced_kind.go new file mode 100644 index 00000000000..a73bc840309 --- /dev/null +++ b/pkg/controller/common/watches/namespaced_kind.go @@ -0,0 +1,44 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. + +package watches + +import ( + "sigs.k8s.io/controller-runtime/pkg/cache" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/source" + + "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/nsmatch" +) + +// NamespacedKind is a drop-in replacement for source.Kind that injects a +// namespace-scope predicate based on the supplied NamespaceMatcher. When the +// matcher is disabled (legacy / static-resolution modes) the predicate is a +// no-op, so callers can wire this unconditionally. +// +// Use this for every namespaced resource watch. Cluster-scoped watches (e.g. +// webhook configurations, cluster-scoped license state) should continue to use +// source.Kind directly; the matcher already short-circuits empty namespaces, +// but skipping the cache lookup is the cleaner signal. +func NamespacedKind[T client.Object]( + m *nsmatch.NamespaceMatcher, + c cache.Cache, + obj T, + h handler.TypedEventHandler[T, reconcile.Request], + preds ...predicate.TypedPredicate[T], +) source.SyncingSource { + if !m.SelectorEnabled() { + return source.Kind(c, obj, h, preds...) + } + nsPred := predicate.NewTypedPredicateFuncs(func(o T) bool { + return m.Matches(o.GetNamespace()) + }) + all := make([]predicate.TypedPredicate[T], 0, len(preds)+1) + all = append(all, nsPred) + all = append(all, preds...) + return source.Kind(c, obj, h, all...) +} diff --git a/pkg/controller/common/watches/pods.go b/pkg/controller/common/watches/pods.go index 39ebc9a37b0..2d4a2e45552 100644 --- a/pkg/controller/common/watches/pods.go +++ b/pkg/controller/common/watches/pods.go @@ -14,13 +14,14 @@ import ( "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/controller-runtime/pkg/reconcile" - "sigs.k8s.io/controller-runtime/pkg/source" + + "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/nsmatch" ) // WatchPods updates the given controller to enqueue reconciliation requests triggered by changes on Pods. // The resource to reconcile is identified by a label on the Pods. -func WatchPods(mgr manager.Manager, c controller.Controller, objNameLabel string) error { - return c.Watch(source.Kind(mgr.GetCache(), &corev1.Pod{}, handler.TypedEnqueueRequestsFromMapFunc[*corev1.Pod](objToReconcileRequest[*corev1.Pod](objNameLabel)))) +func WatchPods(mgr manager.Manager, c controller.Controller, m *nsmatch.NamespaceMatcher, objNameLabel string) error { + return c.Watch(NamespacedKind(m, mgr.GetCache(), &corev1.Pod{}, handler.TypedEnqueueRequestsFromMapFunc[*corev1.Pod](objToReconcileRequest[*corev1.Pod](objNameLabel)))) } // objToReconcileRequest returns a function to enqueue reconcile requests for the resource name set at objNameLabel. diff --git a/pkg/controller/common/watches/secrets.go b/pkg/controller/common/watches/secrets.go index a9ab6b6d16d..47bbfcf2c8f 100644 --- a/pkg/controller/common/watches/secrets.go +++ b/pkg/controller/common/watches/secrets.go @@ -13,9 +13,9 @@ import ( "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/controller-runtime/pkg/reconcile" - "sigs.k8s.io/controller-runtime/pkg/source" commonv1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/common/v1" + "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/nsmatch" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/reconciler" ) @@ -65,9 +65,9 @@ func WatchUserProvidedNamespacedSecrets( } // WatchSoftOwnedSecrets triggers reconciliations on secrets referencing a soft owner. -func WatchSoftOwnedSecrets(mgr manager.Manager, c controller.Controller, ownerKind string) error { +func WatchSoftOwnedSecrets(mgr manager.Manager, c controller.Controller, m *nsmatch.NamespaceMatcher, ownerKind string) error { return c.Watch( - source.Kind(mgr.GetCache(), &corev1.Secret{}, handler.TypedEnqueueRequestsFromMapFunc[*corev1.Secret](reconcileReqForSoftOwner(ownerKind))), + NamespacedKind(m, mgr.GetCache(), &corev1.Secret{}, handler.TypedEnqueueRequestsFromMapFunc[*corev1.Secret](reconcileReqForSoftOwner(ownerKind))), ) } diff --git a/pkg/controller/common/webhook/resource_validator.go b/pkg/controller/common/webhook/resource_validator.go index 9dca2ef01da..729dda08241 100644 --- a/pkg/controller/common/webhook/resource_validator.go +++ b/pkg/controller/common/webhook/resource_validator.go @@ -15,6 +15,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/webhook/admission" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/license" + "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/nsmatch" ulog "github.com/elastic/cloud-on-k8s/v3/pkg/utils/log" "github.com/elastic/cloud-on-k8s/v3/pkg/utils/set" ) @@ -30,6 +31,7 @@ type ResourceValidator[T runtime.Object] struct { validator admission.Validator[T] managedNamespaces set.StringSet licenseChecker license.Checker + namespaceMatcher *nsmatch.NamespaceMatcher } // NewResourceValidator wraps an admission.Validator[T] with namespace @@ -62,6 +64,14 @@ func NewResourceFuncValidator[T runtime.Object]( return NewResourceValidator(licenseChecker, managedNamespaces, &funcValidator[T]{validate: validate}) } +// WithNamespaceMatcher attaches a NamespaceMatcher so that dynamic-mode +// installs can filter validation by the operator's current selector instead +// of the static managedNamespaces list. +func (v *ResourceValidator[T]) WithNamespaceMatcher(m *nsmatch.NamespaceMatcher) *ResourceValidator[T] { + v.namespaceMatcher = m + return v +} + func (v *ResourceValidator[T]) ValidateCreate(ctx context.Context, obj T) (admission.Warnings, error) { if skip, err := v.preValidate(ctx, obj); skip || err != nil { return nil, err @@ -93,7 +103,20 @@ func (v *ResourceValidator[T]) preValidate(ctx context.Context, obj T) (skip boo ns, _ := accessor.Namespace(obj) name, _ := accessor.Name(obj) - if v.managedNamespaces.Count() > 0 && !v.managedNamespaces.Has(ns) { + if v.namespaceMatcher.SelectorEnabled() { + // The webhook server runs in a single operator pod but receives admission requests + // for every namespace in the cluster: several operator instances, each watching a + // different set of namespaces, may be installed side by side, but only one of their + // webhooks is actually registered. Matches consults the match-state map, which is + // maintained on every replica — including the non-leader one that may be serving + // this webhook. Namespaces that do not match, including those not yet observed, + // are silently let through instead of denied, so an operator never rejects a + // request for a namespace it doesn't manage. + if !v.namespaceMatcher.Matches(ns) { + whlog.V(1).Info("Skip resource validation: namespace does not match selector", "name", name, "namespace", ns) + return true, nil + } + } else if v.managedNamespaces.Count() > 0 && !v.managedNamespaces.Has(ns) { whlog.V(1).Info("Skip resource validation", "name", name, "namespace", ns) return true, nil } @@ -114,8 +137,8 @@ func (v *ResourceValidator[T]) preValidate(ctx context.Context, obj T) (skip boo // RegisterResourceWebhook creates a ResourceValidator wrapping a ValidateFunc // and registers it as a validating webhook at the specified path. -func RegisterResourceWebhook[T runtime.Object](mgr ctrl.Manager, path string, checker license.Checker, managedNamespaces []string, validate ValidateFunc[T], resourceName string) { - v := NewResourceFuncValidator(checker, managedNamespaces, validate) +func RegisterResourceWebhook[T runtime.Object](mgr ctrl.Manager, path string, checker license.Checker, managedNamespaces []string, matcher *nsmatch.NamespaceMatcher, validate ValidateFunc[T], resourceName string) { + v := NewResourceFuncValidator(checker, managedNamespaces, validate).WithNamespaceMatcher(matcher) wh := admission.WithValidator[T](mgr.GetScheme(), v) mgr.GetWebhookServer().Register(path, wh) ulog.Log.Info("Registering validating webhook", "resource", resourceName, "path", path) diff --git a/pkg/controller/elasticsearch/elasticsearch_controller.go b/pkg/controller/elasticsearch/elasticsearch_controller.go index a24ef20a3bc..be47502a9ab 100644 --- a/pkg/controller/elasticsearch/elasticsearch_controller.go +++ b/pkg/controller/elasticsearch/elasticsearch_controller.go @@ -17,12 +17,12 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/types" toolsevents "k8s.io/client-go/tools/events" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/controller-runtime/pkg/reconcile" - "sigs.k8s.io/controller-runtime/pkg/source" esv1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/elasticsearch/v1" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common" @@ -84,42 +84,43 @@ func newReconciler(mgr manager.Manager, params operator.Parameters) *ReconcileEl } func addWatches(mgr manager.Manager, c controller.Controller, r *ReconcileElasticsearch) error { + m := r.NamespaceMatcher // Watch for changes to Elasticsearch if err := c.Watch( - source.Kind(mgr.GetCache(), &esv1.Elasticsearch{}, &handler.TypedEnqueueRequestForObject[*esv1.Elasticsearch]{})); err != nil { + watches.NamespacedKind(m, mgr.GetCache(), &esv1.Elasticsearch{}, &handler.TypedEnqueueRequestForObject[*esv1.Elasticsearch]{})); err != nil { return err } // Watch StatefulSets if err := c.Watch( - source.Kind(mgr.GetCache(), &appsv1.StatefulSet{}, handler.TypedEnqueueRequestForOwner[*appsv1.StatefulSet](mgr.GetScheme(), mgr.GetRESTMapper(), &esv1.Elasticsearch{}, handler.OnlyControllerOwner()))); err != nil { + watches.NamespacedKind(m, mgr.GetCache(), &appsv1.StatefulSet{}, handler.TypedEnqueueRequestForOwner[*appsv1.StatefulSet](mgr.GetScheme(), mgr.GetRESTMapper(), &esv1.Elasticsearch{}, handler.OnlyControllerOwner()))); err != nil { return err } // Watch pods belonging to ES clusters - if err := watches.WatchPods(mgr, c, label.ClusterNameLabelName); err != nil { + if err := watches.WatchPods(mgr, c, m, label.ClusterNameLabelName); err != nil { return err } // Watch services if err := c.Watch( - source.Kind(mgr.GetCache(), &corev1.Service{}, handler.TypedEnqueueRequestForOwner[*corev1.Service](mgr.GetScheme(), mgr.GetRESTMapper(), &esv1.Elasticsearch{}, handler.OnlyControllerOwner()))); err != nil { + watches.NamespacedKind(m, mgr.GetCache(), &corev1.Service{}, handler.TypedEnqueueRequestForOwner[*corev1.Service](mgr.GetScheme(), mgr.GetRESTMapper(), &esv1.Elasticsearch{}, handler.OnlyControllerOwner()))); err != nil { return err } // Watch config maps for dynamic watches (currently used for additional CAs trust) - if err := c.Watch(source.Kind(mgr.GetCache(), &corev1.ConfigMap{}, r.dynamicWatches.ConfigMaps)); err != nil { + if err := c.Watch(watches.NamespacedKind(m, mgr.GetCache(), &corev1.ConfigMap{}, r.dynamicWatches.ConfigMaps)); err != nil { return err } // Watch PodDisruptionBudgets if err := c.Watch( - source.Kind(mgr.GetCache(), &policyv1.PodDisruptionBudget{}, handler.TypedEnqueueRequestForOwner[*policyv1.PodDisruptionBudget](mgr.GetScheme(), mgr.GetRESTMapper(), &esv1.Elasticsearch{}, handler.OnlyControllerOwner()))); err != nil { + watches.NamespacedKind(m, mgr.GetCache(), &policyv1.PodDisruptionBudget{}, handler.TypedEnqueueRequestForOwner[*policyv1.PodDisruptionBudget](mgr.GetScheme(), mgr.GetRESTMapper(), &esv1.Elasticsearch{}, handler.OnlyControllerOwner()))); err != nil { return err } // Watch owned and soft-owned secrets - if err := c.Watch(source.Kind(mgr.GetCache(), &corev1.Secret{}, r.dynamicWatches.Secrets)); err != nil { + if err := c.Watch(watches.NamespacedKind(m, mgr.GetCache(), &corev1.Secret{}, r.dynamicWatches.Secrets)); err != nil { return err } if err := r.dynamicWatches.Secrets.AddHandler(&watches.OwnerWatch[*corev1.Secret]{ @@ -131,12 +132,17 @@ func addWatches(mgr manager.Manager, c controller.Controller, r *ReconcileElasti ); err != nil { return err } - if err := watches.WatchSoftOwnedSecrets(mgr, c, esv1.Kind); err != nil { + if err := watches.WatchSoftOwnedSecrets(mgr, c, m, esv1.Kind); err != nil { return err } // Trigger a reconciliation when observers report a cluster health change - return c.Watch(observer.WatchClusterHealthChange(r.esObservers)) + if err := c.Watch(observer.WatchClusterHealthChange(r.esObservers)); err != nil { + return err + } + return watches.WatchNamespaceFlips(c, mgr.GetCache(), r.NamespaceMatcher, func() client.ObjectList { + return &esv1.ElasticsearchList{} + }) } var _ reconcile.Reconciler = (*ReconcileElasticsearch)(nil) @@ -163,6 +169,10 @@ type ReconcileElasticsearch struct { // Reconcile reads the state of the cluster for an Elasticsearch object and makes changes based on the state read and // what is in the Elasticsearch.Spec func (r *ReconcileElasticsearch) Reconcile(ctx context.Context, request reconcile.Request) (reconcile.Result, error) { + if !r.NamespaceMatcher.Matches(request.Namespace) { + r.onNamespaceOutOfScope(request.NamespacedName) + return reconcile.Result{}, nil + } ctx = common.NewReconciliationContext(ctx, &r.iteration, r.Tracer, name, "es_name", request) defer common.LogReconciliationRun(ulog.FromContext(ctx))() defer tracing.EndContextTransaction(ctx) @@ -374,9 +384,18 @@ func (r *ReconcileElasticsearch) annotateResource( return r.Update(ctx, &es) } -// onDelete garbage collect resources when an Elasticsearch cluster is deleted -func (r *ReconcileElasticsearch) onDelete(ctx context.Context, es types.NamespacedName) error { - r.expectations.RemoveCluster(es) +// onNamespaceOutOfScope releases all controller-local state associated with the given Elasticsearch resource +// when its namespace no longer matches the operator's namespace selector. The resource is not deleted — +// it simply becomes invisible to this operator instance — so we stop observing its cluster health and +// remove all dynamic watches that were registered on its behalf to avoid stale watch handlers. +// +// Do not remove expectations when a namespace moves out of scope. +// The Elasticsearch resource and its child resources still exist, and the +// namespace may move back into scope before the controller cache observes +// recent StatefulSet/pod changes. Keeping expectations preserves the usual +// stale-cache safety across short namespace label flaps. Expectations are +// removed on CR deletion and are also cleared by operator restart. +func (r *ReconcileElasticsearch) onNamespaceOutOfScope(es types.NamespacedName) { r.esObservers.StopObserving(es) r.dynamicWatches.Secrets.RemoveHandlerForKey(keystore.SecureSettingsWatchName(es)) r.dynamicWatches.Secrets.RemoveHandlerForKey(certificates.CertificateWatchKey(esv1.ESNamer, es.Name)) @@ -384,5 +403,11 @@ func (r *ReconcileElasticsearch) onDelete(ctx context.Context, es types.Namespac r.dynamicWatches.Secrets.RemoveHandlerForKey(user.UserProvidedRolesWatchName(es)) r.dynamicWatches.Secrets.RemoveHandlerForKey(user.UserProvidedFileRealmWatchName(es)) r.dynamicWatches.ConfigMaps.RemoveHandlerForKey(transport.AdditionalCAWatchKey(es)) +} + +// onDelete garbage collect resources when an Elasticsearch cluster is deleted +func (r *ReconcileElasticsearch) onDelete(ctx context.Context, es types.NamespacedName) error { + r.expectations.RemoveCluster(es) + r.onNamespaceOutOfScope(es) return reconciler.GarbageCollectSoftOwnedSecrets(ctx, r.Client, es, esv1.Kind) } diff --git a/pkg/controller/elasticsearch/validation/webhook.go b/pkg/controller/elasticsearch/validation/webhook.go index f809a5c3854..734840c2e0d 100644 --- a/pkg/controller/elasticsearch/validation/webhook.go +++ b/pkg/controller/elasticsearch/validation/webhook.go @@ -15,6 +15,7 @@ import ( esv1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/elasticsearch/v1" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/license" + "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/nsmatch" commonwebhook "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/webhook" "github.com/elastic/cloud-on-k8s/v3/pkg/utils/k8s" ulog "github.com/elastic/cloud-on-k8s/v3/pkg/utils/log" @@ -29,7 +30,7 @@ const ( var eslog = ulog.Log.WithName("es-validation") // RegisterWebhook registers the Elasticsearch validating webhook. -func RegisterWebhook(mgr ctrl.Manager, validateStorageClass bool, exposedNodeLabels NodeLabels, licenseChecker license.Checker, managedNamespaces []string) { +func RegisterWebhook(mgr ctrl.Manager, validateStorageClass bool, exposedNodeLabels NodeLabels, licenseChecker license.Checker, managedNamespaces []string, matcher *nsmatch.NamespaceMatcher) { inner := &validator{ client: mgr.GetClient(), validateStorageClass: validateStorageClass, @@ -38,7 +39,7 @@ func RegisterWebhook(mgr ctrl.Manager, validateStorageClass bool, exposedNodeLab } // License checks run inside validations(), so we pass nil here // (the reconciler calls ValidateElasticsearch directly). - v := commonwebhook.NewResourceValidator[*esv1.Elasticsearch](nil, managedNamespaces, inner) + v := commonwebhook.NewResourceValidator[*esv1.Elasticsearch](nil, managedNamespaces, inner).WithNamespaceMatcher(matcher) eslog.Info("Registering Elasticsearch validating webhook", "path", webhookPath) wh := admission.WithValidator[*esv1.Elasticsearch](mgr.GetScheme(), v) mgr.GetWebhookServer().Register(webhookPath, wh) diff --git a/pkg/controller/enterprisesearch/enterprisesearch_controller.go b/pkg/controller/enterprisesearch/enterprisesearch_controller.go index 921a0a9b2a8..30b63dc2406 100644 --- a/pkg/controller/enterprisesearch/enterprisesearch_controller.go +++ b/pkg/controller/enterprisesearch/enterprisesearch_controller.go @@ -19,11 +19,11 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/types" toolsevents "k8s.io/client-go/tools/events" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/controller-runtime/pkg/reconcile" - "sigs.k8s.io/controller-runtime/pkg/source" entv1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/enterprisesearch/v1" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/association" @@ -69,14 +69,15 @@ func newReconciler(mgr manager.Manager, params operator.Parameters) *ReconcileEn } func addWatches(mgr manager.Manager, c controller.Controller, r *ReconcileEnterpriseSearch) error { + m := r.NamespaceMatcher // Watch for changes to EnterpriseSearch - err := c.Watch(source.Kind(mgr.GetCache(), &entv1.EnterpriseSearch{}, &handler.TypedEnqueueRequestForObject[*entv1.EnterpriseSearch]{})) + err := c.Watch(watches.NamespacedKind(m, mgr.GetCache(), &entv1.EnterpriseSearch{}, &handler.TypedEnqueueRequestForObject[*entv1.EnterpriseSearch]{})) if err != nil { return err } // Watch Deployments - if err := c.Watch(source.Kind(mgr.GetCache(), &appsv1.Deployment{}, handler.TypedEnqueueRequestForOwner[*appsv1.Deployment]( + if err := c.Watch(watches.NamespacedKind(m, mgr.GetCache(), &appsv1.Deployment{}, handler.TypedEnqueueRequestForOwner[*appsv1.Deployment]( mgr.GetScheme(), mgr.GetRESTMapper(), &entv1.EnterpriseSearch{}, handler.OnlyControllerOwner(), ))); err != nil { @@ -85,12 +86,12 @@ func addWatches(mgr manager.Manager, c controller.Controller, r *ReconcileEnterp // Watch Pods, to ensure `status.version` and version upgrades are correctly reconciled on any change. // Watching Deployments only may lead to missing some events. - if err := watches.WatchPods(mgr, c, EnterpriseSearchNameLabelName); err != nil { + if err := watches.WatchPods(mgr, c, m, EnterpriseSearchNameLabelName); err != nil { return err } // Watch services - if err := c.Watch(source.Kind(mgr.GetCache(), &corev1.Service{}, handler.TypedEnqueueRequestForOwner[*corev1.Service]( + if err := c.Watch(watches.NamespacedKind(m, mgr.GetCache(), &corev1.Service{}, handler.TypedEnqueueRequestForOwner[*corev1.Service]( mgr.GetScheme(), mgr.GetRESTMapper(), &entv1.EnterpriseSearch{}, handler.OnlyControllerOwner(), ))); err != nil { @@ -98,18 +99,23 @@ func addWatches(mgr manager.Manager, c controller.Controller, r *ReconcileEnterp } // Watch owned and soft-owned secrets - if err := c.Watch(source.Kind(mgr.GetCache(), &corev1.Secret{}, handler.TypedEnqueueRequestForOwner[*corev1.Secret]( + if err := c.Watch(watches.NamespacedKind(m, mgr.GetCache(), &corev1.Secret{}, handler.TypedEnqueueRequestForOwner[*corev1.Secret]( mgr.GetScheme(), mgr.GetRESTMapper(), &entv1.EnterpriseSearch{}, handler.OnlyControllerOwner(), ))); err != nil { return err } - if err := watches.WatchSoftOwnedSecrets(mgr, c, entv1.Kind); err != nil { + if err := watches.WatchSoftOwnedSecrets(mgr, c, m, entv1.Kind); err != nil { return err } // Dynamically watch referenced secrets to connect to Elasticsearch - return c.Watch(source.Kind(mgr.GetCache(), &corev1.Secret{}, r.dynamicWatches.Secrets)) + if err := c.Watch(watches.NamespacedKind(m, mgr.GetCache(), &corev1.Secret{}, r.dynamicWatches.Secrets)); err != nil { + return err + } + return watches.WatchNamespaceFlips(c, mgr.GetCache(), r.NamespaceMatcher, func() client.ObjectList { + return &entv1.EnterpriseSearchList{} + }) } var _ reconcile.Reconciler = (*ReconcileEnterpriseSearch)(nil) @@ -141,6 +147,10 @@ var _ driver.Interface = (*ReconcileEnterpriseSearch)(nil) // Reconcile reads that state of the cluster for an EnterpriseSearch object and makes changes based on the state read // and what is in the EnterpriseSearch.Spec. func (r *ReconcileEnterpriseSearch) Reconcile(ctx context.Context, request reconcile.Request) (reconcile.Result, error) { + if !r.NamespaceMatcher.Matches(request.Namespace) { + r.onNamespaceOutOfScope(request.NamespacedName) + return reconcile.Result{}, nil + } ctx = common.NewReconciliationContext(ctx, &r.iteration, r.Tracer, controllerName, "ent_name", request) defer common.LogReconciliationRun(ulog.FromContext(ctx))() defer tracing.EndContextTransaction(ctx) @@ -172,11 +182,13 @@ func (r *ReconcileEnterpriseSearch) Reconcile(ctx context.Context, request recon return results.Aggregate() } -func (r *ReconcileEnterpriseSearch) onDelete(ctx context.Context, obj types.NamespacedName) error { - // Clean up watches +func (r *ReconcileEnterpriseSearch) onNamespaceOutOfScope(obj types.NamespacedName) { r.dynamicWatches.Secrets.RemoveHandlerForKey(common.ConfigRefWatchName(obj)) - // Clean up watches set on custom http tls certificates r.dynamicWatches.Secrets.RemoveHandlerForKey(certificates.CertificateWatchKey(entv1.Namer, obj.Name)) +} + +func (r *ReconcileEnterpriseSearch) onDelete(ctx context.Context, obj types.NamespacedName) error { + r.onNamespaceOutOfScope(obj) return reconciler.GarbageCollectSoftOwnedSecrets(ctx, r.Client, obj, entv1.Kind) } diff --git a/pkg/controller/kibana/controller.go b/pkg/controller/kibana/controller.go index 0d3011cbd85..b4f9ae0ed7a 100644 --- a/pkg/controller/kibana/controller.go +++ b/pkg/controller/kibana/controller.go @@ -16,11 +16,11 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/types" toolsevents "k8s.io/client-go/tools/events" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/controller-runtime/pkg/reconcile" - "sigs.k8s.io/controller-runtime/pkg/source" kbv1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/kibana/v1" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common" @@ -64,13 +64,14 @@ func newReconciler(mgr manager.Manager, params operator.Parameters) *ReconcileKi } func addWatches(mgr manager.Manager, c controller.Controller, r *ReconcileKibana) error { + m := r.params.NamespaceMatcher // Watch for changes to Kibana - if err := c.Watch(source.Kind(mgr.GetCache(), &kbv1.Kibana{}, &handler.TypedEnqueueRequestForObject[*kbv1.Kibana]{})); err != nil { + if err := c.Watch(watches.NamespacedKind(m, mgr.GetCache(), &kbv1.Kibana{}, &handler.TypedEnqueueRequestForObject[*kbv1.Kibana]{})); err != nil { return err } // Watch deployments - if err := c.Watch(source.Kind(mgr.GetCache(), &appsv1.Deployment{}, handler.TypedEnqueueRequestForOwner[*appsv1.Deployment]( + if err := c.Watch(watches.NamespacedKind(m, mgr.GetCache(), &appsv1.Deployment{}, handler.TypedEnqueueRequestForOwner[*appsv1.Deployment]( mgr.GetScheme(), mgr.GetRESTMapper(), &kbv1.Kibana{}, handler.OnlyControllerOwner(), ))); err != nil { @@ -79,30 +80,30 @@ func addWatches(mgr manager.Manager, c controller.Controller, r *ReconcileKibana // Watch Pods, to ensure `status.version` and version upgrades are correctly reconciled on any change. // Watching Deployments only may lead to missing some events. - if err := watches.WatchPods(mgr, c, kblabel.KibanaNameLabelName); err != nil { + if err := watches.WatchPods(mgr, c, m, kblabel.KibanaNameLabelName); err != nil { return err } // Watch services - if err := c.Watch(source.Kind(mgr.GetCache(), &corev1.Service{}, handler.TypedEnqueueRequestForOwner[*corev1.Service]( + if err := c.Watch(watches.NamespacedKind(m, mgr.GetCache(), &corev1.Service{}, handler.TypedEnqueueRequestForOwner[*corev1.Service]( mgr.GetScheme(), mgr.GetRESTMapper(), &kbv1.Kibana{}, handler.OnlyControllerOwner(), ))); err != nil { return err } // Watch owned and soft-owned secrets - if err := c.Watch(source.Kind(mgr.GetCache(), &corev1.Secret{}, handler.TypedEnqueueRequestForOwner[*corev1.Secret]( + if err := c.Watch(watches.NamespacedKind(m, mgr.GetCache(), &corev1.Secret{}, handler.TypedEnqueueRequestForOwner[*corev1.Secret]( mgr.GetScheme(), mgr.GetRESTMapper(), &kbv1.Kibana{}, handler.OnlyControllerOwner(), ))); err != nil { return err } - if err := watches.WatchSoftOwnedSecrets(mgr, c, kbv1.Kind); err != nil { + if err := watches.WatchSoftOwnedSecrets(mgr, c, m, kbv1.Kind); err != nil { return err } // Watch configmaps - if err := c.Watch(source.Kind(mgr.GetCache(), &corev1.ConfigMap{}, handler.TypedEnqueueRequestForOwner[*corev1.ConfigMap]( + if err := c.Watch(watches.NamespacedKind(m, mgr.GetCache(), &corev1.ConfigMap{}, handler.TypedEnqueueRequestForOwner[*corev1.ConfigMap]( mgr.GetScheme(), mgr.GetRESTMapper(), &kbv1.Kibana{}, handler.OnlyControllerOwner(), ))); err != nil { @@ -110,7 +111,12 @@ func addWatches(mgr manager.Manager, c controller.Controller, r *ReconcileKibana } // dynamically watch referenced secrets to connect to Elasticsearch - return c.Watch(source.Kind(mgr.GetCache(), &corev1.Secret{}, r.dynamicWatches.Secrets)) + if err := c.Watch(watches.NamespacedKind(m, mgr.GetCache(), &corev1.Secret{}, r.dynamicWatches.Secrets)); err != nil { + return err + } + return watches.WatchNamespaceFlips(c, mgr.GetCache(), r.params.NamespaceMatcher, func() client.ObjectList { + return &kbv1.KibanaList{} + }) } var _ reconcile.Reconciler = (*ReconcileKibana)(nil) @@ -131,6 +137,10 @@ type ReconcileKibana struct { // Reconcile reads that state of the cluster for a Kibana object and makes changes based on the state read and what is // in the Kibana.Spec func (r *ReconcileKibana) Reconcile(ctx context.Context, request reconcile.Request) (reconcile.Result, error) { + if !r.params.NamespaceMatcher.Matches(request.Namespace) { + r.onNamespaceOutOfScope(request.NamespacedName) + return reconcile.Result{}, nil + } ctx = common.NewReconciliationContext(ctx, &r.iteration, r.params.Tracer, controllerName, "kibana_name", request) defer common.LogReconciliationRun(ulog.FromContext(ctx))() defer tracing.EndContextTransaction(ctx) @@ -244,11 +254,13 @@ func (r *ReconcileKibana) updateStatus(ctx context.Context, state State) error { return common.UpdateStatus(ctx, r.Client, state.Kibana) } -func (r *ReconcileKibana) onDelete(ctx context.Context, obj types.NamespacedName) error { - // Clean up watches set on secure settings +func (r *ReconcileKibana) onNamespaceOutOfScope(obj types.NamespacedName) { r.dynamicWatches.Secrets.RemoveHandlerForKey(keystore.SecureSettingsWatchName(obj)) - // Clean up watches set on custom http tls certificates r.dynamicWatches.Secrets.RemoveHandlerForKey(certificates.CertificateWatchKey(kbv1.KBNamer, obj.Name)) +} + +func (r *ReconcileKibana) onDelete(ctx context.Context, obj types.NamespacedName) error { + r.onNamespaceOutOfScope(obj) return reconciler.GarbageCollectSoftOwnedSecrets(ctx, r.Client, obj, kbv1.Kind) } diff --git a/pkg/controller/license/license_controller.go b/pkg/controller/license/license_controller.go index 994fdb7cfb9..a2f6775f96c 100644 --- a/pkg/controller/license/license_controller.go +++ b/pkg/controller/license/license_controller.go @@ -10,17 +10,19 @@ import ( "errors" "time" + "github.com/go-logr/logr" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" utilerrors "k8s.io/apimachinery/pkg/util/errors" toolsevents "k8s.io/client-go/tools/events" + "sigs.k8s.io/controller-runtime/pkg/cache" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/controller-runtime/pkg/reconcile" - "sigs.k8s.io/controller-runtime/pkg/source" commonv1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/common/v1" esv1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/elasticsearch/v1" @@ -31,6 +33,7 @@ import ( "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/reconciler" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/tracing" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/version" + "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/watches" esclient "github.com/elastic/cloud-on-k8s/v3/pkg/controller/elasticsearch/client" eslabel "github.com/elastic/cloud-on-k8s/v3/pkg/controller/elasticsearch/label" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/elasticsearch/sset" @@ -52,6 +55,9 @@ const ( // In any case it schedules a new reconcile request to be processed when the license is about to expire. // This happens independently from any watch triggered reconcile request. func (r *ReconcileLicenses) Reconcile(ctx context.Context, request reconcile.Request) (reconcile.Result, error) { + if !r.NamespaceMatcher.Matches(request.Namespace) { + return reconcile.Result{}, nil + } ctx = common.NewReconciliationContext(ctx, &r.iteration, r.Tracer, name, "es_name", request) defer common.LogReconciliationRun(ulog.FromContext(ctx))() defer tracing.EndContextTransaction(ctx) @@ -70,7 +76,7 @@ func Add(mgr manager.Manager, p operator.Parameters) error { if err != nil { return err } - return addWatches(mgr, c, r.Client) + return addWatches(mgr, c, r) } // newReconciler returns a new reconcile.Reconciler @@ -101,16 +107,16 @@ func nextReconcileRelativeTo(now, expiry time.Time, safety time.Duration) time.D return requeueAfter } -// addWatches adds a new Controller to mgr with r as the reconcile.Reconciler -func addWatches(mgr manager.Manager, c controller.Controller, k8sClient k8s.Client) error { +// addWatches adds a new Controller to mgr wresource_validator.go:106ith r as the reconcile.Reconciler +func addWatches(mgr manager.Manager, c controller.Controller, r *ReconcileLicenses) error { log := ulog.Log // no context available for contextual logging // Watch for changes to Elasticsearch clusters. if err := c.Watch( - source.Kind(mgr.GetCache(), &esv1.Elasticsearch{}, &handler.TypedEnqueueRequestForObject[*esv1.Elasticsearch]{})); err != nil { + watches.NamespacedKind(r.NamespaceMatcher, mgr.GetCache(), &esv1.Elasticsearch{}, &handler.TypedEnqueueRequestForObject[*esv1.Elasticsearch]{})); err != nil { return err } - if err := c.Watch(source.Kind(mgr.GetCache(), &corev1.Secret{}, + if err := c.Watch(watches.NamespacedKind(r.NamespaceMatcher, mgr.GetCache(), &corev1.Secret{}, handler.TypedEnqueueRequestsFromMapFunc[*corev1.Secret](func(ctx context.Context, secret *corev1.Secret) []reconcile.Request { if !license.IsOperatorLicense(*secret) { return nil @@ -118,7 +124,7 @@ func addWatches(mgr manager.Manager, c controller.Controller, k8sClient k8s.Clie // if a license is added/modified we want to update for potentially all clusters managed by this instance // of ECK which is why we are listing all Elasticsearch clusters here and trigger a reconciliation - rs, err := reconcileRequestsForAllClusters(k8sClient, log) + rs, err := reconcileRequestsForAllClusters(r.Client, log) if err != nil { // dropping the event(s) at this point log.Error(err, "failed to list affected clusters in enterprise license watch") @@ -129,7 +135,52 @@ func addWatches(mgr manager.Manager, c controller.Controller, k8sClient k8s.Clie )); err != nil { return err } - return nil + + return watches.WatchNamespaceFlipsMapped(c, r.NamespaceMatcher, namespaceFlipRequests(mgr.GetCache(), r.Client, log)) +} + +// namespaceFlipRequests returns the mapper deciding which Elasticsearch clusters to reconcile when a +// namespace's selector match state flips. If the flipped namespace holds an operator license secret, +// the licensing outcome may change for every cluster, so all clusters are reconciled. Otherwise only +// the clusters in the flipped namespace are affected. +// +// The two clients are used deliberately: lookups inside the flipped namespace go through the cache +// (ch) directly because the filtering client would hide the namespace's contents when it is being +// descoped, and we still need to see them to decide what to re-enqueue. The cluster-wide list in +// reconcileRequestsForAllClusters goes through the filtering client (clt) instead: its match state +// is already updated when the flip event fires, so it yields exactly the clusters currently in +// scope — including the newly scoped namespace and excluding the descoped one, whose clusters we +// must not reconcile. +func namespaceFlipRequests(ch cache.Cache, clt client.Client, log logr.Logger) func(context.Context, *corev1.Namespace) []reconcile.Request { + return func(ctx context.Context, ns *corev1.Namespace) []reconcile.Request { + var licenseSecrets corev1.SecretList + if err := ch.List(ctx, &licenseSecrets, client.InNamespace(ns.Name), license.NewLicenseByScopeSelector(license.LicenseScopeOperator)); err != nil { + log.Error(err, "failed to list license secrets in namespace flip watch", "namespace", ns.Name) + return nil + } + + // the flipped namespace carries a license with it: the licensing outcome may change for every cluster. + if len(licenseSecrets.Items) > 0 { + rs, err := reconcileRequestsForAllClusters(clt, log) + if err != nil { + log.Error(err, "failed to list all clusters in namespace flip watch") + return nil + } + return rs + } + + // no license involved: only the clusters in the flipped namespace are affected. + var clusters esv1.ElasticsearchList + if err := ch.List(ctx, &clusters, client.InNamespace(ns.Name)); err != nil { + log.Error(err, "failed to list clusters in namespace flip watch", "namespace", ns.Name) + return nil + } + reqs := make([]reconcile.Request, 0, len(clusters.Items)) + for i := range clusters.Items { + reqs = append(reqs, reconcile.Request{NamespacedName: k8s.ExtractNamespacedName(&clusters.Items[i])}) + } + return reqs + } } var _ reconcile.Reconciler = (*ReconcileLicenses)(nil) diff --git a/pkg/controller/license/license_controller_integration_test.go b/pkg/controller/license/license_controller_integration_test.go index 6ce8a9276bc..4d2bf1ef866 100644 --- a/pkg/controller/license/license_controller_integration_test.go +++ b/pkg/controller/license/license_controller_integration_test.go @@ -29,6 +29,7 @@ import ( esv1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/elasticsearch/v1" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/license" + "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/nsmatch" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/operator" esclient "github.com/elastic/cloud-on-k8s/v3/pkg/controller/elasticsearch/client" "github.com/elastic/cloud-on-k8s/v3/pkg/utils/chrono" @@ -43,14 +44,17 @@ func TestMain(m *testing.M) { func TestReconcile(t *testing.T) { c, stop := test.StartManager(t, func(mgr manager.Manager, p operator.Parameters) error { r := &ReconcileLicenses{ - Client: mgr.GetClient(), + Client: mgr.GetClient(), + Parameters: operator.Parameters{ + NamespaceMatcher: nsmatch.NewNamespaceMatcher(nil, "elastic-system"), + }, checker: license.MockLicenseChecker{EnterpriseEnabled: true}, } c, err := common.NewController(mgr, name, r, p) if err != nil { return err } - return addWatches(mgr, c, r.Client) + return addWatches(mgr, c, r) }, operator.Parameters{}) defer stop() diff --git a/pkg/controller/license/license_controller_test.go b/pkg/controller/license/license_controller_test.go index 2d402910e6c..c4b49bbfb42 100644 --- a/pkg/controller/license/license_controller_test.go +++ b/pkg/controller/license/license_controller_test.go @@ -7,13 +7,17 @@ package license import ( "context" "encoding/json" + "errors" "testing" "time" + "github.com/go-logr/logr" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" crclient "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" @@ -23,6 +27,7 @@ import ( "github.com/elastic/cloud-on-k8s/v3/pkg/controller/elasticsearch/client" "github.com/elastic/cloud-on-k8s/v3/pkg/utils/chrono" "github.com/elastic/cloud-on-k8s/v3/pkg/utils/k8s" + cachemock "github.com/elastic/cloud-on-k8s/v3/pkg/utils/test/mock" ) func Test_nextReconcileRelativeTo(t *testing.T) { @@ -211,3 +216,82 @@ func TestReconcileLicenses_reconcileInternal(t *testing.T) { }) } } + +func Test_namespaceFlipRequests(t *testing.T) { + // the namespace whose selector match state just changed + changedNS := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "ns-a"}} + + operatorLicense := corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "eck-license", + Namespace: changedNS.Name, + Labels: map[string]string{commonlicense.LicenseLabelScope: string(commonlicense.LicenseScopeOperator)}, + }, + } + esA := esv1.Elasticsearch{ObjectMeta: metav1.ObjectMeta{Name: "es-a", Namespace: "ns-a"}} + esB := esv1.Elasticsearch{ObjectMeta: metav1.ObjectMeta{Name: "es-b", Namespace: "ns-b"}} + + tests := []struct { + name string + licenseSecrets []corev1.Secret // license secrets in the changed namespace, listed from the cache + secretListErr error + clusters []esv1.Elasticsearch // clusters in the changed namespace, listed from the cache + clusterListErr error + clientClusters []crclient.Object // clusters visible to the filtering client + want []reconcile.Request + }{ + { + name: "license in the changed namespace: reconcile all clusters visible to the client", + licenseSecrets: []corev1.Secret{operatorLicense}, + clientClusters: []crclient.Object{esA.DeepCopy(), esB.DeepCopy()}, + want: []reconcile.Request{ + {NamespacedName: types.NamespacedName{Namespace: "ns-a", Name: "es-a"}}, + {NamespacedName: types.NamespacedName{Namespace: "ns-b", Name: "es-b"}}, + }, + }, + { + name: "no license in the changed namespace: reconcile only its clusters", + clusters: []esv1.Elasticsearch{esA}, + // the filtering client must not be consulted here: this cluster must not show up + clientClusters: []crclient.Object{esB.DeepCopy()}, + want: []reconcile.Request{ + {NamespacedName: types.NamespacedName{Namespace: "ns-a", Name: "es-a"}}, + }, + }, + { + name: "error listing license secrets: drop the event", + secretListErr: errors.New("cache not started"), + }, + { + name: "error listing clusters: drop the event", + clusterListErr: errors.New("cache not started"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ch := cachemock.NewCache(t) + ch.On("List", mock.Anything, mock.AnythingOfType("*v1.SecretList"), mock.Anything). + Run(func(args mock.Arguments) { + // the license secret lookup must be scoped to the changed namespace and the operator license scope + require.Contains(t, args.Get(2), crclient.InNamespace(changedNS.Name)) + require.Contains(t, args.Get(2), commonlicense.NewLicenseByScopeSelector(commonlicense.LicenseScopeOperator)) + args.Get(1).(*corev1.SecretList).Items = tt.licenseSecrets //nolint:forcetypeassert + }). + Return(tt.secretListErr) + // the cluster list from the cache only happens when no license secret was found + if tt.secretListErr == nil && len(tt.licenseSecrets) == 0 { + ch.On("List", mock.Anything, mock.AnythingOfType("*v1.ElasticsearchList"), mock.Anything). + Run(func(args mock.Arguments) { + require.Contains(t, args.Get(2), crclient.InNamespace(changedNS.Name)) + args.Get(1).(*esv1.ElasticsearchList).Items = tt.clusters //nolint:forcetypeassert + }). + Return(tt.clusterListErr) + } + + reqs := namespaceFlipRequests(ch, k8s.NewFakeClient(tt.clientClusters...), logr.Discard())(context.Background(), changedNS) + + require.ElementsMatch(t, tt.want, reqs) + }) + } +} diff --git a/pkg/controller/license/trial/trial_controller.go b/pkg/controller/license/trial/trial_controller.go index 04577b15915..6c3da7e2f85 100644 --- a/pkg/controller/license/trial/trial_controller.go +++ b/pkg/controller/license/trial/trial_controller.go @@ -20,13 +20,13 @@ import ( "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/controller-runtime/pkg/reconcile" - "sigs.k8s.io/controller-runtime/pkg/source" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common" licensing "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/license" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/operator" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/reconciler" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/tracing" + "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/watches" "github.com/elastic/cloud-on-k8s/v3/pkg/utils/k8s" ulog "github.com/elastic/cloud-on-k8s/v3/pkg/utils/log" "github.com/elastic/cloud-on-k8s/v3/pkg/utils/maps" @@ -57,6 +57,9 @@ type ReconcileTrials struct { // If not it starts the trial period if the user has expressed intent to do so. // If a trial is already running it validates the trial license. func (r *ReconcileTrials) Reconcile(ctx context.Context, request reconcile.Request) (reconcile.Result, error) { + if !r.NamespaceMatcher.Matches(request.Namespace) { + return reconcile.Result{}, nil + } ctx = common.NewReconciliationContext(ctx, &r.iteration, r.Tracer, name, "secret_name", request) defer common.LogReconciliationRun(ulog.FromContext(ctx))() defer tracing.EndContextTransaction(ctx) @@ -238,9 +241,9 @@ func newReconciler(mgr manager.Manager, params operator.Parameters) *ReconcileTr } } -func addWatches(mgr manager.Manager, c controller.Controller) error { +func addWatches(mgr manager.Manager, c controller.Controller, r *ReconcileTrials) error { // Watch the trial status secret and the enterprise trial licenses as well - return c.Watch(source.Kind(mgr.GetCache(), &corev1.Secret{}, + return c.Watch(watches.NamespacedKind(r.NamespaceMatcher, mgr.GetCache(), &corev1.Secret{}, handler.TypedEnqueueRequestsFromMapFunc[*corev1.Secret](func(ctx context.Context, secret *corev1.Secret) []reconcile.Request { if licensing.IsEnterpriseTrial(*secret) { return []reconcile.Request{ @@ -276,7 +279,7 @@ func Add(mgr manager.Manager, params operator.Parameters) error { if err != nil { return err } - return addWatches(mgr, c) + return addWatches(mgr, c, r) } var _ reconcile.Reconciler = (*ReconcileTrials)(nil) diff --git a/pkg/controller/logstash/logstash_controller.go b/pkg/controller/logstash/logstash_controller.go index e935262b785..97770de9c91 100644 --- a/pkg/controller/logstash/logstash_controller.go +++ b/pkg/controller/logstash/logstash_controller.go @@ -12,11 +12,11 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/types" toolsevents "k8s.io/client-go/tools/events" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/controller-runtime/pkg/reconcile" - "sigs.k8s.io/controller-runtime/pkg/source" logstashv1alpha1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/logstash/v1alpha1" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/association" @@ -66,14 +66,15 @@ func newReconciler(mgr manager.Manager, params operator.Parameters) *ReconcileLo // addWatches adds watches for all resources this controller cares about func addWatches(mgr manager.Manager, c controller.Controller, r *ReconcileLogstash) error { + m := r.NamespaceMatcher // Watch for changes to Logstash - if err := c.Watch(source.Kind(mgr.GetCache(), &logstashv1alpha1.Logstash{}, &handler.TypedEnqueueRequestForObject[*logstashv1alpha1.Logstash]{})); err != nil { + if err := c.Watch(watches.NamespacedKind(m, mgr.GetCache(), &logstashv1alpha1.Logstash{}, &handler.TypedEnqueueRequestForObject[*logstashv1alpha1.Logstash]{})); err != nil { return err } // Watch StatefulSets if err := c.Watch( - source.Kind(mgr.GetCache(), &appsv1.StatefulSet{}, handler.TypedEnqueueRequestForOwner[*appsv1.StatefulSet]( + watches.NamespacedKind(m, mgr.GetCache(), &appsv1.StatefulSet{}, handler.TypedEnqueueRequestForOwner[*appsv1.StatefulSet]( mgr.GetScheme(), mgr.GetRESTMapper(), &logstashv1alpha1.Logstash{}, handler.OnlyControllerOwner(), ))); err != nil { @@ -82,12 +83,12 @@ func addWatches(mgr manager.Manager, c controller.Controller, r *ReconcileLogsta // Watch Pods, to ensure `status.version` is correctly reconciled on any change. // Watching StatefulSets only may lead to missing some events. - if err := watches.WatchPods(mgr, c, labels.NameLabelName); err != nil { + if err := watches.WatchPods(mgr, c, m, labels.NameLabelName); err != nil { return err } // Watch services - if err := c.Watch(source.Kind(mgr.GetCache(), &corev1.Service{}, handler.TypedEnqueueRequestForOwner[*corev1.Service]( + if err := c.Watch(watches.NamespacedKind(m, mgr.GetCache(), &corev1.Service{}, handler.TypedEnqueueRequestForOwner[*corev1.Service]( mgr.GetScheme(), mgr.GetRESTMapper(), &logstashv1alpha1.Logstash{}, handler.OnlyControllerOwner(), ))); err != nil { @@ -95,18 +96,23 @@ func addWatches(mgr manager.Manager, c controller.Controller, r *ReconcileLogsta } // Watch owned and soft-owned secrets - if err := c.Watch(source.Kind(mgr.GetCache(), &corev1.Secret{}, handler.TypedEnqueueRequestForOwner[*corev1.Secret]( + if err := c.Watch(watches.NamespacedKind(m, mgr.GetCache(), &corev1.Secret{}, handler.TypedEnqueueRequestForOwner[*corev1.Secret]( mgr.GetScheme(), mgr.GetRESTMapper(), &logstashv1alpha1.Logstash{}, handler.OnlyControllerOwner(), ))); err != nil { return err } - if err := watches.WatchSoftOwnedSecrets(mgr, c, logstashv1alpha1.Kind); err != nil { + if err := watches.WatchSoftOwnedSecrets(mgr, c, m, logstashv1alpha1.Kind); err != nil { return err } // Watch dynamically referenced Secrets - return c.Watch(source.Kind(mgr.GetCache(), &corev1.Secret{}, r.dynamicWatches.Secrets)) + if err := c.Watch(watches.NamespacedKind(m, mgr.GetCache(), &corev1.Secret{}, r.dynamicWatches.Secrets)); err != nil { + return err + } + return watches.WatchNamespaceFlips(c, mgr.GetCache(), r.NamespaceMatcher, func() client.ObjectList { + return &logstashv1alpha1.LogstashList{} + }) } var _ reconcile.Reconciler = (*ReconcileLogstash)(nil) @@ -130,6 +136,10 @@ type ReconcileLogstash struct { // +kubebuilder:rbac:groups=logstash.k8s.elastic.co,resources=logstashes,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=logstash.k8s.elastic.co,resources=logstashes/status,verbs=get;update;patch func (r *ReconcileLogstash) Reconcile(ctx context.Context, request reconcile.Request) (reconcile.Result, error) { + if !r.NamespaceMatcher.Matches(request.Namespace) { + r.onNamespaceOutOfScope(request.NamespacedName) + return reconcile.Result{}, nil + } ctx = common.NewReconciliationContext(ctx, &r.iteration, r.Tracer, controllerName, "logstash_name", request) defer common.LogReconciliationRun(ulog.FromContext(ctx))() defer tracing.EndContextTransaction(ctx) @@ -226,10 +236,14 @@ func (r *ReconcileLogstash) validate(ctx context.Context, logstash logstashv1alp return nil } -func (r *ReconcileLogstash) onDelete(ctx context.Context, obj types.NamespacedName) error { - r.expectations.RemoveCluster(obj) +func (r *ReconcileLogstash) onNamespaceOutOfScope(obj types.NamespacedName) { r.dynamicWatches.Secrets.RemoveHandlerForKey(keystore.SecureSettingsWatchName(obj)) r.dynamicWatches.Secrets.RemoveHandlerForKey(common.ConfigRefWatchName(obj)) r.dynamicWatches.Secrets.RemoveHandlerForKey(pipelines.RefWatchName(obj)) +} + +func (r *ReconcileLogstash) onDelete(ctx context.Context, obj types.NamespacedName) error { + r.onNamespaceOutOfScope(obj) + r.expectations.RemoveCluster(obj) return reconciler.GarbageCollectSoftOwnedSecrets(ctx, r.Client, obj, logstashv1alpha1.Kind) } diff --git a/pkg/controller/logstash/validation/webhook.go b/pkg/controller/logstash/validation/webhook.go index 464e2139fac..bdb9b6baa0a 100644 --- a/pkg/controller/logstash/validation/webhook.go +++ b/pkg/controller/logstash/validation/webhook.go @@ -15,6 +15,7 @@ import ( commonv1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/common/v1" lsv1alpha1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/logstash/v1alpha1" + "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/nsmatch" commonwebhook "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/webhook" "github.com/elastic/cloud-on-k8s/v3/pkg/utils/k8s" ulog "github.com/elastic/cloud-on-k8s/v3/pkg/utils/log" @@ -29,13 +30,13 @@ const ( var lslog = ulog.Log.WithName("ls-validation") // RegisterWebhook registers the Logstash validating webhook. -func RegisterWebhook(mgr ctrl.Manager, validateStorageClass bool, managedNamespaces []string) { +func RegisterWebhook(mgr ctrl.Manager, validateStorageClass bool, managedNamespaces []string, matcher *nsmatch.NamespaceMatcher) { inner := &validator{ client: mgr.GetClient(), validateStorageClass: validateStorageClass, } // Logstash has no license-dependent validation, so we pass nil here. - v := commonwebhook.NewResourceValidator[*lsv1alpha1.Logstash](nil, managedNamespaces, inner) + v := commonwebhook.NewResourceValidator[*lsv1alpha1.Logstash](nil, managedNamespaces, inner).WithNamespaceMatcher(matcher) lslog.Info("Registering Logstash validating webhook", "path", webhookPath) wh := admission.WithValidator[*lsv1alpha1.Logstash](mgr.GetScheme(), v) mgr.GetWebhookServer().Register(webhookPath, wh) diff --git a/pkg/controller/maps/controller.go b/pkg/controller/maps/controller.go index a458f0b71ec..dfa54c836ff 100644 --- a/pkg/controller/maps/controller.go +++ b/pkg/controller/maps/controller.go @@ -18,11 +18,11 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/types" toolsevents "k8s.io/client-go/tools/events" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/controller-runtime/pkg/reconcile" - "sigs.k8s.io/controller-runtime/pkg/source" emsv1alpha1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/maps/v1alpha1" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/association" @@ -73,13 +73,14 @@ func newReconciler(mgr manager.Manager, params operator.Parameters) *ReconcileMa } func addWatches(mgr manager.Manager, c controller.Controller, r *ReconcileMapsServer) error { + m := r.NamespaceMatcher // Watch for changes to MapsServer - if err := c.Watch(source.Kind(mgr.GetCache(), &emsv1alpha1.ElasticMapsServer{}, &handler.TypedEnqueueRequestForObject[*emsv1alpha1.ElasticMapsServer]{})); err != nil { + if err := c.Watch(watches.NamespacedKind(m, mgr.GetCache(), &emsv1alpha1.ElasticMapsServer{}, &handler.TypedEnqueueRequestForObject[*emsv1alpha1.ElasticMapsServer]{})); err != nil { return err } // Watch deployments - if err := c.Watch(source.Kind(mgr.GetCache(), &appsv1.Deployment{}, handler.TypedEnqueueRequestForOwner[*appsv1.Deployment]( + if err := c.Watch(watches.NamespacedKind(m, mgr.GetCache(), &appsv1.Deployment{}, handler.TypedEnqueueRequestForOwner[*appsv1.Deployment]( mgr.GetScheme(), mgr.GetRESTMapper(), &emsv1alpha1.ElasticMapsServer{}, handler.OnlyControllerOwner(), ))); err != nil { @@ -88,12 +89,12 @@ func addWatches(mgr manager.Manager, c controller.Controller, r *ReconcileMapsSe // Watch Pods, to ensure `status.version` and version upgrades are correctly reconciled on any change. // Watching Deployments only may lead to missing some events. - if err := watches.WatchPods(mgr, c, NameLabelName); err != nil { + if err := watches.WatchPods(mgr, c, m, NameLabelName); err != nil { return err } // Watch services - if err := c.Watch(source.Kind(mgr.GetCache(), &corev1.Service{}, handler.TypedEnqueueRequestForOwner[*corev1.Service]( + if err := c.Watch(watches.NamespacedKind(m, mgr.GetCache(), &corev1.Service{}, handler.TypedEnqueueRequestForOwner[*corev1.Service]( mgr.GetScheme(), mgr.GetRESTMapper(), &emsv1alpha1.ElasticMapsServer{}, handler.OnlyControllerOwner(), ))); err != nil { @@ -101,18 +102,23 @@ func addWatches(mgr manager.Manager, c controller.Controller, r *ReconcileMapsSe } // Watch owned and soft-owned secrets - if err := c.Watch(source.Kind(mgr.GetCache(), &corev1.Secret{}, handler.TypedEnqueueRequestForOwner[*corev1.Secret]( + if err := c.Watch(watches.NamespacedKind(m, mgr.GetCache(), &corev1.Secret{}, handler.TypedEnqueueRequestForOwner[*corev1.Secret]( mgr.GetScheme(), mgr.GetRESTMapper(), &emsv1alpha1.ElasticMapsServer{}, handler.OnlyControllerOwner(), ))); err != nil { return err } - if err := watches.WatchSoftOwnedSecrets(mgr, c, emsv1alpha1.Kind); err != nil { + if err := watches.WatchSoftOwnedSecrets(mgr, c, m, emsv1alpha1.Kind); err != nil { return err } // Dynamically watch referenced secrets to connect to Elasticsearch - return c.Watch(source.Kind(mgr.GetCache(), &corev1.Secret{}, r.dynamicWatches.Secrets)) + if err := c.Watch(watches.NamespacedKind(m, mgr.GetCache(), &corev1.Secret{}, r.dynamicWatches.Secrets)); err != nil { + return err + } + return watches.WatchNamespaceFlips(c, mgr.GetCache(), r.NamespaceMatcher, func() client.ObjectList { + return &emsv1alpha1.ElasticMapsServerList{} + }) } var _ reconcile.Reconciler = (*ReconcileMapsServer)(nil) @@ -145,6 +151,10 @@ var _ driver.Interface = (*ReconcileMapsServer)(nil) // Reconcile reads that state of the cluster for a MapsServer object and makes changes based on the state read and what is // in the MapsServer.Spec func (r *ReconcileMapsServer) Reconcile(ctx context.Context, request reconcile.Request) (reconcile.Result, error) { + if !r.NamespaceMatcher.Matches(request.Namespace) { + r.onNamespaceOutOfScope(request.NamespacedName) + return reconcile.Result{}, nil + } ctx = common.NewReconciliationContext(ctx, &r.iteration, r.Tracer, controllerName, "maps_name", request) defer common.LogReconciliationRun(ulog.FromContext(ctx))() defer tracing.EndContextTransaction(ctx) @@ -423,10 +433,12 @@ func (r *ReconcileMapsServer) updateStatus(ctx context.Context, ems emsv1alpha1. return common.UpdateStatus(ctx, r.Client, &ems) } -func (r *ReconcileMapsServer) onDelete(ctx context.Context, obj types.NamespacedName) error { - // Clean up watches set on custom http tls certificates +func (r *ReconcileMapsServer) onNamespaceOutOfScope(obj types.NamespacedName) { r.dynamicWatches.Secrets.RemoveHandlerForKey(certificates.CertificateWatchKey(EMSNamer, obj.Name)) - // same for the configRef secret r.dynamicWatches.Secrets.RemoveHandlerForKey(common.ConfigRefWatchName(obj)) +} + +func (r *ReconcileMapsServer) onDelete(ctx context.Context, obj types.NamespacedName) error { + r.onNamespaceOutOfScope(obj) return reconciler.GarbageCollectSoftOwnedSecrets(ctx, r.Client, obj, emsv1alpha1.Kind) } diff --git a/pkg/controller/namespace/controller.go b/pkg/controller/namespace/controller.go new file mode 100644 index 00000000000..8d5c99af2bf --- /dev/null +++ b/pkg/controller/namespace/controller.go @@ -0,0 +1,164 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. + +// Package namespace contains the match-state controller used in dynamic +// namespaceSelector mode. It watches Namespace objects and, whenever a +// namespace's match-state against the configured selector changes, broadcasts +// the Namespace to all per-kind controllers via a NamespaceMatcher. +// Each per-kind controller subscribes to the notifier and uses +// handler.EnqueueRequestsFromMapFunc to list its own CRs in the namespace and +// enqueue reconcile requests — no API writes are needed. +package namespace + +import ( + "context" + "fmt" + "time" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + toolsevents "k8s.io/client-go/tools/events" + "sigs.k8s.io/controller-runtime/pkg/cache" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/source" + + "github.com/go-logr/logr" + + "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/license" + "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/nsmatch" + "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/operator" + ulog "github.com/elastic/cloud-on-k8s/v3/pkg/utils/log" +) + +const controllerName = "namespace-controller" + +// Add registers the namespace match-state controller with the manager. It is +// only meaningful when the matcher is enabled (dynamic mode); the caller +// should skip registration otherwise. +func Add(mgr manager.Manager, params operator.Parameters) error { + if !params.NamespaceMatcher.SelectorEnabled() { + return nil + } + r := &reconciler{ + cache: mgr.GetCache(), + nsMatchNotifier: params.NamespaceMatcher, + licenseChecker: license.NewLicenseChecker(mgr.GetClient(), params.OperatorNamespace), + recorder: mgr.GetEventRecorder(controllerName), + operatorNamespace: params.OperatorNamespace, + } + + seedLog := mgr.GetLogger().WithName(controllerName).WithName("seeder") + if err := mgr.Add(&namespaceSeedRunnable{log: seedLog, client: mgr.GetCache(), namespaceMatcher: params.NamespaceMatcher}); err != nil { + return fmt.Errorf("registering namespace init runnable: %w", err) + } + // Unlike the per-kind controllers, this controller runs on every replica + // (leader or not): the webhook server and the namespace-filtering client + // consult the match-state map on non-leaders too, so each replica maintains + // its own copy from the same watch stream. Broadcasting to subscribers is + // still leader-only, enforced inside NamespaceMatcher.Broadcast. + c, err := controller.New(controllerName, mgr, controller.Options{ + Reconciler: r, + MaxConcurrentReconciles: params.MaxConcurrentReconciles, + NeedLeaderElection: new(false), + }) + if err != nil { + return err + } + return c.Watch(source.Kind(mgr.GetCache(), &corev1.Namespace{}, &handler.TypedEnqueueRequestForObject[*corev1.Namespace]{})) +} + +type reconciler struct { + cache cache.Cache + nsMatchNotifier *nsmatch.NamespaceMatcher + licenseChecker license.Checker + recorder toolsevents.EventRecorder + operatorNamespace string +} + +func (r *reconciler) Reconcile(ctx context.Context, request reconcile.Request) (reconcile.Result, error) { + log := ulog.FromContext(ctx).WithValues("namespace", request.Name) + + enabled, err := r.licenseChecker.EnterpriseFeaturesEnabled(ctx) + if err != nil { + return reconcile.Result{}, err + } + if !enabled { + const msg = "Dynamic namespace selector is an enterprise feature. Enterprise features are disabled" + log.V(1).Info(msg) + license.EmitEnterpriseFeatureEvent(r.recorder, r.operatorNamespace, msg) + return reconcile.Result{RequeueAfter: 5 * time.Minute}, nil + } + + return r.doReconcile(ctx, log, request) +} + +func (r *reconciler) doReconcile(ctx context.Context, log logr.Logger, request reconcile.Request) (reconcile.Result, error) { + var ns corev1.Namespace + if err := r.cache.Get(ctx, types.NamespacedName{Name: request.Name}, &ns); err != nil { + if apierrors.IsNotFound(err) { + log.Info("namespace deleted") + // Namespace was deleted: mark it as non-matching without broadcasting. All resources + // that lived in it are being deleted or cleaned up by their own controllers, so there + // is nothing for the namespace-selector logic to react to. + r.nsMatchNotifier.ForgetNamespace(request.Name) + return reconcile.Result{}, nil + } + log.Error(err, "error while fetching namespace") + return reconcile.Result{}, err + } + + stateChanged, isMatching, err := r.nsMatchNotifier.ObserveAndBroadcast(ctx, &ns) + if err != nil { + log.Error(err, "error while broadcasting namespace match change", "namespace", ns.Name) + } + if stateChanged { + log.Info("namespace match-state changed", "matches", isMatching) + } + + return reconcile.Result{}, nil +} + +var _ reconcile.Reconciler = (*reconciler)(nil) + +// namespaceSeedRunnable seeds the NamespaceMatcher with the current match state +// of all existing namespaces. Registered via mgr.Add, it starts after the +// manager's cache has synced, so it reads from the cache rather than the API +// server. The manager provides no ordering between this runnable and the +// controllers: they all start concurrently, so watch predicates and reconcile +// loops may briefly observe an unseeded matcher (Matches returning false). +// Correctness does not depend on that ordering: ObserveAndBroadcast broadcasts +// every false->true transition it seeds, and each per-kind controller's +// namespace-flip watch reacts by re-enqueueing all CRs in the broadcast +// namespace, backfilling any events the predicates dropped during the +// cold-start window. +// +// It runs on every replica (NeedLeaderElection is false) so that non-leaders +// maintain the same match-state map as the leader; on non-leaders the +// broadcasts its seeding emits are no-ops (see NamespaceMatcher.Broadcast). +type namespaceSeedRunnable struct { + log logr.Logger + client client.Reader + namespaceMatcher *nsmatch.NamespaceMatcher +} + +func (r *namespaceSeedRunnable) Start(ctx context.Context) error { + var nsList corev1.NamespaceList + if err := r.client.List(ctx, &nsList); err != nil { + return err + } + + // Run in a goroutine so that ObserveAndBroadcast can block waiting for + // subscribers to start consuming without holding up the Runnable that + // called us. + go r.namespaceMatcher.InitialSeed(ctx, r.log, nsList.Items) + + return nil +} + +func (r *namespaceSeedRunnable) NeedLeaderElection() bool { return false } diff --git a/pkg/controller/namespace/controller_test.go b/pkg/controller/namespace/controller_test.go new file mode 100644 index 00000000000..6256f6b1328 --- /dev/null +++ b/pkg/controller/namespace/controller_test.go @@ -0,0 +1,356 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. + +package namespace + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/go-logr/logr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/events" + "sigs.k8s.io/controller-runtime/pkg/cache" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/license" + "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/nsmatch" + "github.com/elastic/cloud-on-k8s/v3/pkg/utils/k8s" + "github.com/elastic/cloud-on-k8s/v3/pkg/utils/test/mock" +) + +// errLicenseChecker embeds MockLicenseChecker and overrides EnterpriseFeaturesEnabled +// to return a configurable error. +type errLicenseChecker struct { + license.MockLicenseChecker + err error +} + +func (e errLicenseChecker) EnterpriseFeaturesEnabled(context.Context) (bool, error) { + return false, e.err +} + +func makeNamespace(name string, lbls map[string]string) *corev1.Namespace { + return &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: name, Labels: lbls}, + } +} + +func nsRequest(name string) reconcile.Request { + return reconcile.Request{NamespacedName: types.NamespacedName{Name: name}} +} + +func TestReconciler_doReconcile(t *testing.T) { + sel := labels.SelectorFromSet(labels.Set{"managed": "true"}) + + matchingNS := makeNamespace("matching-ns", map[string]string{"managed": "true"}) + nonMatchingNS := makeNamespace("non-matching-ns", map[string]string{"managed": "false"}) + + tests := []struct { + name string + buildCache func(t *testing.T) cache.Cache + preloadState func(*nsmatch.NamespaceMatcher) + request reconcile.Request + wantResult reconcile.Result + wantErr bool + wantBroadcast bool + }{ + { + name: "namespace not found: state forgotten, no broadcast", + buildCache: func(t *testing.T) cache.Cache { + t.Helper() + mc := mock.NewCache(t) + mc.OnGetSetNamespace(nil).Return(apierrors.NewNotFound(corev1.Resource("namespaces"), "deleted-ns")) + return mc + }, + preloadState: func(m *nsmatch.NamespaceMatcher) { + t.Helper() + m.Swap("deleted-ns", true) + }, + request: nsRequest("deleted-ns"), + wantResult: reconcile.Result{}, + wantBroadcast: false, + }, + { + name: "client error: error is propagated, no broadcast", + buildCache: func(t *testing.T) cache.Cache { + t.Helper() + mc := mock.NewCache(t) + mc.OnGetSetNamespace(nil).Return(errors.New("connection refused")) + return mc + }, + request: nsRequest("any-ns"), + wantErr: true, + wantBroadcast: false, + }, + { + name: "first reconcile, namespace matches: broadcast (unknown -> matching)", + buildCache: func(t *testing.T) cache.Cache { + t.Helper() + mc := mock.NewCache(t) + mc.OnGetSetNamespace(matchingNS.Labels).Return(nil) + return mc + }, + request: nsRequest(matchingNS.Name), + wantResult: reconcile.Result{}, + wantBroadcast: true, + }, + { + name: "first reconcile, namespace does not match: no broadcast (unknown -> non-matching)", + buildCache: func(t *testing.T) cache.Cache { + t.Helper() + mc := mock.NewCache(t) + mc.OnGetSetNamespace(nonMatchingNS.Labels).Return(nil) + return mc + }, + request: nsRequest(nonMatchingNS.Name), + wantResult: reconcile.Result{}, + wantBroadcast: false, + }, + { + name: "state unchanged: namespace still matches, no broadcast", + buildCache: func(t *testing.T) cache.Cache { + t.Helper() + mc := mock.NewCache(t) + mc.OnGetSetNamespace(matchingNS.Labels).Return(nil) + return mc + }, + preloadState: func(m *nsmatch.NamespaceMatcher) { + m.Swap(matchingNS.Name, true) + }, + request: nsRequest(matchingNS.Name), + wantResult: reconcile.Result{}, + wantBroadcast: false, + }, + { + name: "state unchanged: namespace still does not match, no broadcast", + buildCache: func(t *testing.T) cache.Cache { + t.Helper() + mc := mock.NewCache(t) + mc.OnGetSetNamespace(nonMatchingNS.Labels).Return(nil) + return mc + }, + preloadState: func(m *nsmatch.NamespaceMatcher) { + m.Swap(nonMatchingNS.Name, false) + }, + request: nsRequest(nonMatchingNS.Name), + wantResult: reconcile.Result{}, + wantBroadcast: false, + }, + { + name: "state change: namespace transitions matching -> non-matching, broadcast", + buildCache: func(t *testing.T) cache.Cache { + t.Helper() + mc := mock.NewCache(t) + mc.OnGetSetNamespace(nonMatchingNS.Labels).Return(nil) + return mc + }, + preloadState: func(m *nsmatch.NamespaceMatcher) { + m.Swap(nonMatchingNS.Name, true) // previously matched + }, + request: nsRequest(nonMatchingNS.Name), + wantResult: reconcile.Result{}, + wantBroadcast: true, + }, + { + name: "state change: namespace transitions non-matching -> matching, broadcast", + buildCache: func(t *testing.T) cache.Cache { + t.Helper() + mc := mock.NewCache(t) + mc.OnGetSetNamespace(matchingNS.Labels).Return(nil) + return mc + }, + preloadState: func(m *nsmatch.NamespaceMatcher) { + m.Swap(matchingNS.Name, false) // previously did not match + }, + request: nsRequest(matchingNS.Name), + wantResult: reconcile.Result{}, + wantBroadcast: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + notifier := nsmatch.NewNamespaceMatcher(sel, "") + if tt.preloadState != nil { + tt.preloadState(notifier) + } + + r := &reconciler{ + cache: tt.buildCache(t), + nsMatchNotifier: notifier, + } + sub := r.nsMatchNotifier.Subscribe() + result, err := r.doReconcile(t.Context(), logr.Discard(), tt.request) + + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + assert.Equal(t, tt.wantResult, result) + + if tt.wantBroadcast { + require.Len(t, sub, 1, "expected exactly one broadcast event") + evt := <-sub + assert.Equal(t, tt.request.Name, evt.Object.Name) + } else { + assert.Empty(t, sub, "expected no broadcast event") + } + }) + } +} + +func TestNsInitRunnable_Start(t *testing.T) { + sel := labels.SelectorFromSet(labels.Set{"managed": "true"}) + + matchingNS := makeNamespace("matching-ns", map[string]string{"managed": "true"}) + nonMatchingNS := makeNamespace("non-matching-ns", map[string]string{"managed": "false"}) + + tests := []struct { + name string + buildClient func() k8s.Client + wantErr bool + wantStates map[string]bool // ns name -> expected match state after Start + }{ + { + name: "list error is propagated", + buildClient: func() k8s.Client { + return k8s.NewFakeClientBuilder(). + WithInterceptorFuncs(interceptor.Funcs{ + List: func(_ context.Context, _ client.WithWatch, _ client.ObjectList, _ ...client.ListOption) error { + return errors.New("list failed") + }, + }). + Build() + }, + wantErr: true, + }, + { + name: "no namespaces: notifier state remains empty", + buildClient: func() k8s.Client { return k8s.NewFakeClient() }, + wantStates: map[string]bool{}, + }, + { + name: "matching namespace is seeded as matching", + buildClient: func() k8s.Client { return k8s.NewFakeClient(matchingNS) }, + wantStates: map[string]bool{matchingNS.Name: true}, + }, + { + name: "non-matching namespace is seeded as non-matching", + buildClient: func() k8s.Client { return k8s.NewFakeClient(nonMatchingNS) }, + wantStates: map[string]bool{nonMatchingNS.Name: false}, + }, + { + name: "mixed namespaces: each seeded with correct match state", + buildClient: func() k8s.Client { return k8s.NewFakeClient(matchingNS, nonMatchingNS) }, + wantStates: map[string]bool{ + matchingNS.Name: true, + nonMatchingNS.Name: false, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + notifier := nsmatch.NewNamespaceMatcher(sel, "") + r := &namespaceSeedRunnable{ + client: tt.buildClient(), + namespaceMatcher: notifier, + } + + err := r.Start(t.Context()) + + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + + // Start launches a goroutine; poll until all expected states are visible. + require.Eventually(t, func() bool { + for ns, wantMatch := range tt.wantStates { + if notifier.Matches(ns) != wantMatch { + return false + } + } + return true + }, time.Second, time.Millisecond) + + for ns, wantMatch := range tt.wantStates { + wasMatching := notifier.Swap(ns, false) + assert.Equal(t, wantMatch, wasMatching, "unexpected match state for namespace %q", ns) + } + }) + } +} + +func TestReconciler_Reconcile(t *testing.T) { + sel := labels.SelectorFromSet(labels.Set{"managed": "true"}) + matchingNS := makeNamespace("matching-ns", map[string]string{"managed": "true"}) + + tests := []struct { + name string + lc license.Checker + wantResult reconcile.Result + wantErr bool + wantGet bool + }{ + { + name: "license check error: error returned", + lc: errLicenseChecker{err: errors.New("license check failed")}, + wantErr: true, + }, + { + name: "enterprise disabled: requeue after 5m, no reconcile", + lc: license.MockLicenseChecker{EnterpriseEnabled: false}, + wantResult: reconcile.Result{RequeueAfter: 5 * time.Minute}, + }, + { + name: "enterprise enabled: delegates to doReconcile", + lc: license.MockLicenseChecker{EnterpriseEnabled: true}, + wantResult: reconcile.Result{}, + wantGet: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fr := events.NewFakeRecorder(10) + mc := mock.NewCache(t) + if tt.wantGet { + mc.OnGetSetNamespace(matchingNS.Labels).Return(nil) + } + r := &reconciler{ + cache: mc, + nsMatchNotifier: nsmatch.NewNamespaceMatcher(sel, ""), + licenseChecker: tt.lc, + recorder: fr, + } + + result, err := r.Reconcile(t.Context(), nsRequest(matchingNS.Name)) + + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + assert.Equal(t, tt.wantResult, result) + + if ok, _ := r.licenseChecker.EnterpriseFeaturesEnabled(t.Context()); !ok && !tt.wantErr { + assert.Equal(t, "Warning InvalidLicense Dynamic namespace selector is an enterprise feature. Enterprise features are disabled", <-fr.Events) + } + }) + } +} diff --git a/pkg/controller/packageregistry/controller.go b/pkg/controller/packageregistry/controller.go index 852b8daff2d..7c9bbf2c8d7 100644 --- a/pkg/controller/packageregistry/controller.go +++ b/pkg/controller/packageregistry/controller.go @@ -17,11 +17,11 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/types" toolsevents "k8s.io/client-go/tools/events" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/controller-runtime/pkg/reconcile" - "sigs.k8s.io/controller-runtime/pkg/source" eprv1alpha1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/packageregistry/v1alpha1" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common" @@ -67,13 +67,14 @@ func newReconciler(mgr manager.Manager, params operator.Parameters) *ReconcilePa } func addWatches(mgr manager.Manager, c controller.Controller, r *ReconcilePackageRegistry) error { + m := r.NamespaceMatcher // Watch for changes to packageregistry - if err := c.Watch(source.Kind(mgr.GetCache(), &eprv1alpha1.PackageRegistry{}, &handler.TypedEnqueueRequestForObject[*eprv1alpha1.PackageRegistry]{})); err != nil { + if err := c.Watch(watches.NamespacedKind(m, mgr.GetCache(), &eprv1alpha1.PackageRegistry{}, &handler.TypedEnqueueRequestForObject[*eprv1alpha1.PackageRegistry]{})); err != nil { return err } // Watch deployments - if err := c.Watch(source.Kind(mgr.GetCache(), &appsv1.Deployment{}, handler.TypedEnqueueRequestForOwner[*appsv1.Deployment]( + if err := c.Watch(watches.NamespacedKind(m, mgr.GetCache(), &appsv1.Deployment{}, handler.TypedEnqueueRequestForOwner[*appsv1.Deployment]( mgr.GetScheme(), mgr.GetRESTMapper(), &eprv1alpha1.PackageRegistry{}, handler.OnlyControllerOwner(), ))); err != nil { @@ -82,12 +83,12 @@ func addWatches(mgr manager.Manager, c controller.Controller, r *ReconcilePackag // Watch Pods, to ensure `status.version` and version upgrades are correctly reconciled on any change. // Watching Deployments only may lead to missing some events. - if err := watches.WatchPods(mgr, c, label.NameLabelName); err != nil { + if err := watches.WatchPods(mgr, c, m, label.NameLabelName); err != nil { return err } // Watch services - if err := c.Watch(source.Kind(mgr.GetCache(), &corev1.Service{}, handler.TypedEnqueueRequestForOwner[*corev1.Service]( + if err := c.Watch(watches.NamespacedKind(m, mgr.GetCache(), &corev1.Service{}, handler.TypedEnqueueRequestForOwner[*corev1.Service]( mgr.GetScheme(), mgr.GetRESTMapper(), &eprv1alpha1.PackageRegistry{}, handler.OnlyControllerOwner(), ))); err != nil { @@ -95,18 +96,23 @@ func addWatches(mgr manager.Manager, c controller.Controller, r *ReconcilePackag } // Watch owned and soft-owned secrets - if err := c.Watch(source.Kind(mgr.GetCache(), &corev1.Secret{}, handler.TypedEnqueueRequestForOwner[*corev1.Secret]( + if err := c.Watch(watches.NamespacedKind(m, mgr.GetCache(), &corev1.Secret{}, handler.TypedEnqueueRequestForOwner[*corev1.Secret]( mgr.GetScheme(), mgr.GetRESTMapper(), &eprv1alpha1.PackageRegistry{}, handler.OnlyControllerOwner(), ))); err != nil { return err } - if err := watches.WatchSoftOwnedSecrets(mgr, c, eprv1alpha1.Kind); err != nil { + if err := watches.WatchSoftOwnedSecrets(mgr, c, m, eprv1alpha1.Kind); err != nil { return err } // Dynamically watch referenced secrets - return c.Watch(source.Kind(mgr.GetCache(), &corev1.Secret{}, r.dynamicWatches.Secrets)) + if err := c.Watch(watches.NamespacedKind(m, mgr.GetCache(), &corev1.Secret{}, r.dynamicWatches.Secrets)); err != nil { + return err + } + return watches.WatchNamespaceFlips(c, mgr.GetCache(), r.NamespaceMatcher, func() client.ObjectList { + return &eprv1alpha1.PackageRegistryList{} + }) } var _ reconcile.Reconciler = (*ReconcilePackageRegistry)(nil) @@ -138,6 +144,10 @@ var _ driver.Interface = (*ReconcilePackageRegistry)(nil) // Reconcile reads that state of the cluster for a PackageRegistry object and makes changes based on the state read and what is // in the PackageRegistry.Spec func (r *ReconcilePackageRegistry) Reconcile(ctx context.Context, request reconcile.Request) (reconcile.Result, error) { + if !r.NamespaceMatcher.Matches(request.Namespace) { + r.onNamespaceOutOfScope(request.NamespacedName) + return reconcile.Result{}, nil + } ctx = common.NewReconciliationContext(ctx, &r.iteration, r.Tracer, controllerName, "epr_name", request) log := ulog.FromContext(ctx) defer common.LogReconciliationRun(log)() @@ -349,11 +359,13 @@ func (r *ReconcilePackageRegistry) updateStatus(ctx context.Context, epr eprv1al return common.UpdateStatus(ctx, r.Client, &epr) } -func (r *ReconcilePackageRegistry) onDelete(ctx context.Context, obj types.NamespacedName) error { - // Clean up watches set on custom http tls certificates +func (r *ReconcilePackageRegistry) onNamespaceOutOfScope(obj types.NamespacedName) { r.dynamicWatches.Secrets.RemoveHandlerForKey(certificates.CertificateWatchKey(eprv1alpha1.Namer, obj.Name)) - // same for the configRef secret r.dynamicWatches.Secrets.RemoveHandlerForKey(common.ConfigRefWatchName(obj)) +} + +func (r *ReconcilePackageRegistry) onDelete(ctx context.Context, obj types.NamespacedName) error { + r.onNamespaceOutOfScope(obj) return reconciler.GarbageCollectSoftOwnedSecrets(ctx, r.Client, obj, eprv1alpha1.Kind) } diff --git a/pkg/controller/remotecluster/controller.go b/pkg/controller/remotecluster/controller.go index 7a28706f7d9..8d3f833cc3f 100644 --- a/pkg/controller/remotecluster/controller.go +++ b/pkg/controller/remotecluster/controller.go @@ -90,6 +90,10 @@ type ReconcileRemoteClusters struct { // Reconcile reads that state of the cluster for the expected remote clusters in this Kubernetes cluster. // It copies the remote CA Secrets so they can be trusted by every peer Elasticsearch clusters. func (r *ReconcileRemoteClusters) Reconcile(ctx context.Context, request reconcile.Request) (reconcile.Result, error) { + if !r.NamespaceMatcher.Matches(request.Namespace) { + r.onNamespaceOutOfScope(request.NamespacedName) + return reconcile.Result{}, nil + } ctx = common.NewReconciliationContext(ctx, &r.iteration, r.Tracer, name, "es_name", request) defer common.LogReconciliationRun(ulog.FromContext(ctx))() defer tracing.EndContextTransaction(ctx) @@ -99,7 +103,7 @@ func (r *ReconcileRemoteClusters) Reconcile(ctx context.Context, request reconci err := r.Get(ctx, request.NamespacedName, &es) if err != nil { if errors.IsNotFound(err) { - r.keystoreProvider.ForgetCluster(request.NamespacedName) + r.onNamespaceOutOfScope(request.NamespacedName) return deleteAllRemoteCa(ctx, r, request.NamespacedName) } return reconcile.Result{}, err @@ -112,6 +116,10 @@ func (r *ReconcileRemoteClusters) Reconcile(ctx context.Context, request reconci return doReconcile(ctx, r, &es) } +func (r *ReconcileRemoteClusters) onNamespaceOutOfScope(obj types.NamespacedName) { + r.keystoreProvider.ForgetCluster(obj) +} + // deleteAllRemoteCa deletes all associated remote certificate authorities func deleteAllRemoteCa(ctx context.Context, r *ReconcileRemoteClusters, es types.NamespacedName) (reconcile.Result, error) { span, _ := apm.StartSpan(ctx, "delete_all_remote_ca", tracing.SpanTypeApp) @@ -209,6 +217,11 @@ func doReconcile( if errors.IsNotFound(err) { // Remote client cluster does not exist, invalidate API keys for that client cluster. apiKeyReconciledRemoteClients.Insert(remoteClientKey) + + // The remote client cluster is deleted or out of scope. Only its identity is + // known; set it explicitly so API key invalidation can match its keys. + remoteClient.Namespace = remoteClientKey.Namespace + remoteClient.Name = remoteClientKey.Name results.WithResults(reconcileAPIKeys(ctx, r.Client, activeAPIKeys, remoteServer, remoteClient, nil, esClient, r.keystoreProvider)) continue } diff --git a/pkg/controller/remotecluster/watches.go b/pkg/controller/remotecluster/watches.go index 00cf55687dc..5383de7b20f 100644 --- a/pkg/controller/remotecluster/watches.go +++ b/pkg/controller/remotecluster/watches.go @@ -10,11 +10,11 @@ import ( corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/cache" "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/controller-runtime/pkg/reconcile" - "sigs.k8s.io/controller-runtime/pkg/source" commonv1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/common/v1" esv1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/elasticsearch/v1" @@ -23,19 +23,22 @@ import ( "github.com/elastic/cloud-on-k8s/v3/pkg/controller/elasticsearch/certificates/transport" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/elasticsearch/label" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/remotecluster/keystore" + "github.com/elastic/cloud-on-k8s/v3/pkg/utils/k8s" "github.com/elastic/cloud-on-k8s/v3/pkg/utils/maps" ) // AddWatches set watches on objects needed to manage the association between a local and a remote cluster. func addWatches(mgr manager.Manager, c controller.Controller, r *ReconcileRemoteClusters) error { + m := r.NamespaceMatcher // Watch for changes to RemoteCluster - if err := c.Watch(source.Kind(mgr.GetCache(), &esv1.Elasticsearch{}, &handler.TypedEnqueueRequestForObject[*esv1.Elasticsearch]{})); err != nil { + if err := c.Watch(watches.NamespacedKind(m, mgr.GetCache(), &esv1.Elasticsearch{}, &handler.TypedEnqueueRequestForObject[*esv1.Elasticsearch]{})); err != nil { return err } // Emit changes to remote clusters to update API keys. if err := c.Watch( - source.Kind( + watches.NamespacedKind( + m, mgr.GetCache(), &esv1.Elasticsearch{}, handler.TypedEnqueueRequestsFromMapFunc[*esv1.Elasticsearch, reconcile.Request]( @@ -56,25 +59,94 @@ func addWatches(mgr manager.Manager, c controller.Controller, r *ReconcileRemote // * Remote certificate authorities managed by this controller. // * API keys if err := c.Watch( - source.Kind(mgr.GetCache(), &corev1.Secret{}, + watches.NamespacedKind(m, mgr.GetCache(), &corev1.Secret{}, handler.TypedEnqueueRequestsFromMapFunc[*corev1.Secret, reconcile.Request](newRequestsFromMatchedLabels()), )); err != nil { return err } // Dynamically watches the certificate authorities involved in a cluster relationship - if err := c.Watch(source.Kind(mgr.GetCache(), &corev1.Secret{}, r.watches.Secrets)); err != nil { + if err := c.Watch(watches.NamespacedKind(m, mgr.GetCache(), &corev1.Secret{}, r.watches.Secrets)); err != nil { return err } - return r.watches.Secrets.AddHandlers( + if err := r.watches.Secrets.AddHandlers( &watches.OwnerWatch[*corev1.Secret]{ Scheme: mgr.GetScheme(), Mapper: mgr.GetRESTMapper(), OwnerType: &esv1.Elasticsearch{}, IsController: true, }, - ) + ); err != nil { + return err + } + + return watches.WatchNamespaceFlipsMapped(c, r.NamespaceMatcher, namespaceFlipRequests(mgr.GetCache())) +} + +// namespaceFlipRequests returns a mapper translating a namespace match-state change into +// reconcile requests for the Elasticsearch clusters affected by it: the ones living in the +// flipped namespace, the ones living elsewhere whose Spec.RemoteClusters reference a cluster +// in the flipped namespace, and the counterparts referenced by the flipped namespace's own +// clusters. Reconcile re-evaluates all of a cluster's remote cluster relationships in both +// directions (see getExpectedRemoteClientsFor), so enqueueing the counterparts is enough for +// them to establish or tear down trust; the FilterClient then decides what remains visible. +func namespaceFlipRequests(cache cache.Cache) func(context.Context, *corev1.Namespace) []reconcile.Request { + return func(ctx context.Context, ns *corev1.Namespace) []reconcile.Request { + var list esv1.ElasticsearchList + // List **cluster-wide** from the cache (not the FilterClient): affected clusters can + // live in any matched namespace, and clusters in the namespace being de-scoped would + // be hidden by the FilterClient. + if err := cache.List(ctx, &list); err != nil { + return nil + } + + seen := make(map[types.NamespacedName]struct{}) + enqueue := func(nsn types.NamespacedName) { + if _, ok := seen[nsn]; ok { + return + } + seen[nsn] = struct{}{} + } + + for _, es := range list.Items { + if es.Namespace == ns.Name { + // case A: the cluster lives in the flipped namespace. + enqueue(k8s.ExtractNamespacedName(&es)) + + // case B: the counterparts its own spec declares, wherever they live. + for _, remoteCluster := range es.Spec.RemoteClusters { + if !remoteCluster.ElasticsearchRef.IsSet() { + continue + } + enqueue(remoteCluster.ElasticsearchRef.WithDefaultNamespace(es.Namespace).NamespacedName()) + } + continue + } + + // case C: the cluster lives elsewhere but references a cluster in the flipped + // namespace. Its side of the relationship (remote CA copies, API keys) depends on + // that counterpart's visibility, which just changed, so it must be re-reconciled. + for _, remoteCluster := range es.Spec.RemoteClusters { + if !remoteCluster.ElasticsearchRef.IsSet() { + continue + } + // Compare the ref's effective namespace: WithDefaultNamespace resolves an + // unset ref namespace the same way getExpectedRemoteClientsFor does, i.e. to + // the declaring cluster's own namespace — which is not the flipped one here. + if remoteCluster.ElasticsearchRef.WithDefaultNamespace(es.Namespace).NamespacedName().Namespace == ns.Name { + enqueue(k8s.ExtractNamespacedName(&es)) + break + } + } + } + + reqs := make([]reconcile.Request, 0, len(seen)) + for nsn := range seen { + reqs = append(reqs, reconcile.Request{NamespacedName: nsn}) + } + return reqs + } } // newRequestsFromMatchedLabels creates a watch handler function that creates reconcile requests based on the @@ -88,9 +160,11 @@ func newRequestsFromMatchedLabels() handler.TypedMapFunc[*corev1.Secret, reconci return nil } return []reconcile.Request{ - {NamespacedName: types.NamespacedName{ - Namespace: labels[RemoteClusterNamespaceLabelName], - Name: labels[RemoteClusterNameLabelName]}, + { + NamespacedName: types.NamespacedName{ + Namespace: labels[RemoteClusterNamespaceLabelName], + Name: labels[RemoteClusterNameLabelName], + }, }, } } @@ -101,9 +175,11 @@ func newRequestsFromMatchedLabels() handler.TypedMapFunc[*corev1.Secret, reconci } // Remote cluster API keys Secret event. return []reconcile.Request{ - {NamespacedName: types.NamespacedName{ - Namespace: obj.Namespace, - Name: labels[label.ClusterNameLabelName]}, + { + NamespacedName: types.NamespacedName{ + Namespace: obj.Namespace, + Name: labels[label.ClusterNameLabelName], + }, }, } } @@ -127,7 +203,8 @@ func watchName(local types.NamespacedName, remote types.NamespacedName) string { // The remote CAs are watched to update the trusted certificates of the local cluster. func addCertificatesAuthorityWatches( reconcileClusterAssociation *ReconcileRemoteClusters, - local, remote types.NamespacedName) error { + local, remote types.NamespacedName, +) error { // Watch the CA secret of Elasticsearch clusters which are involved in a association. err := reconcileClusterAssociation.watches.Secrets.AddHandler(watches.NamedWatch[*corev1.Secret]{ Name: watchName(local, remote), diff --git a/pkg/controller/remotecluster/watches_test.go b/pkg/controller/remotecluster/watches_test.go new file mode 100644 index 00000000000..588fa50aab2 --- /dev/null +++ b/pkg/controller/remotecluster/watches_test.go @@ -0,0 +1,127 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. + +package remotecluster + +import ( + "context" + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + commonv1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/common/v1" + esv1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/elasticsearch/v1" + cachemock "github.com/elastic/cloud-on-k8s/v3/pkg/utils/test/mock" +) + +func Test_namespaceFlipRequests(t *testing.T) { + es := func(name, namespace string, remoteRefs ...commonv1.LocalObjectSelector) esv1.Elasticsearch { + remoteClusters := make([]esv1.RemoteCluster, 0, len(remoteRefs)) + for _, ref := range remoteRefs { + remoteClusters = append(remoteClusters, esv1.RemoteCluster{Name: ref.Name, ElasticsearchRef: ref}) + } + return esv1.Elasticsearch{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, + Spec: esv1.ElasticsearchSpec{RemoteClusters: remoteClusters}, + } + } + ref := func(name, namespace string) commonv1.LocalObjectSelector { + return commonv1.LocalObjectSelector{Name: name, Namespace: namespace} + } + req := func(namespace, name string) reconcile.Request { + return reconcile.Request{NamespacedName: types.NamespacedName{Namespace: namespace, Name: name}} + } + + // "flipped" is the namespace whose match state just changed, "scoped" is a namespace + // that remains in scope. + tests := []struct { + name string + clusters []esv1.Elasticsearch + want []reconcile.Request + }{ + { + name: "case A: clusters living in the flipped namespace are enqueued regardless of refs", + clusters: []esv1.Elasticsearch{ + es("local", "flipped"), + es("standalone", "scoped"), + }, + want: []reconcile.Request{ + req("flipped", "local"), + }, + }, + { + // A cluster declaring counterparts necessarily lives in the flipped namespace, + // so its own case A request is always part of the result. + name: "case B: counterparts declared by the flipped namespace's clusters are enqueued wherever they live", + clusters: []esv1.Elasticsearch{ + es("local", "flipped", + ref("counterpart", "scoped"), + // implicit same-namespace ref + ref("sibling", ""), + // unset ref (e.g. remote cluster outside this k8s cluster): skipped + ref("", ""), + ), + }, + want: []reconcile.Request{ + req("flipped", "local"), + req("scoped", "counterpart"), + req("flipped", "sibling"), + }, + }, + { + name: "case C: clusters elsewhere are enqueued only if they reference into the flipped namespace", + clusters: []esv1.Elasticsearch{ + es("cross-ref", "scoped", ref("es", "flipped")), + // references a cluster in an unrelated namespace: not enqueued + es("unrelated", "scoped", ref("es", "elsewhere")), + // an unset ref namespace defaults to the cluster's own namespace: not enqueued + es("self-ref", "scoped", ref("es", "")), + es("standalone", "scoped"), + }, + want: []reconcile.Request{ + req("scoped", "cross-ref"), + }, + }, + { + name: "mixed: overlapping cases produce each request exactly once", + clusters: []esv1.Elasticsearch{ + // case A, and case B declaring "b" (also a case C match) and "a2" + // (also a case A match) via an implicit same-namespace ref + es("a", "flipped", ref("b", "scoped"), ref("a2", "")), + // case A, deduped with a's counterpart ref + es("a2", "flipped"), + // case C, deduped with a's counterpart ref + es("b", "scoped", ref("a", "flipped")), + es("unrelated", "scoped", ref("es", "elsewhere")), + }, + want: []reconcile.Request{ + req("flipped", "a"), + req("flipped", "a2"), + req("scoped", "b"), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := cachemock.NewCache(t) + c.On("List", mock.Anything, mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + list := args.Get(1).(*esv1.ElasticsearchList) //nolint:forcetypeassert + list.Items = append(list.Items, tt.clusters...) + }).Return(nil) + + reqs := namespaceFlipRequests(c)( + context.Background(), + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "flipped"}}, + ) + + require.ElementsMatch(t, tt.want, reqs) + }) + } +} diff --git a/pkg/controller/stackconfigpolicy/controller.go b/pkg/controller/stackconfigpolicy/controller.go index 2f1ebfc8f50..050f2e9c341 100644 --- a/pkg/controller/stackconfigpolicy/controller.go +++ b/pkg/controller/stackconfigpolicy/controller.go @@ -21,12 +21,12 @@ import ( "k8s.io/apimachinery/pkg/types" toolsevents "k8s.io/client-go/tools/events" "k8s.io/client-go/util/retry" + "sigs.k8s.io/controller-runtime/pkg/cache" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller" "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/controller-runtime/pkg/reconcile" - "sigs.k8s.io/controller-runtime/pkg/source" commonv1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/common/v1" esv1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/elasticsearch/v1" @@ -56,11 +56,9 @@ const ( controllerName = "stackconfigpolicy-controller" ) -var ( - // defaultRequeue is the default requeue interval for this controller. It is longer than the default interval used elsewhere to account - // for secret propagation times and the time it takes for Elasticsearch to observe the updates. - defaultRequeue = 30 * time.Second -) +// defaultRequeue is the default requeue interval for this controller. It is longer than the default interval used elsewhere to account +// for secret propagation times and the time it takes for Elasticsearch to observe the updates. +var defaultRequeue = 30 * time.Second // Add creates a new StackConfigPolicy Controller and adds it to the Manager with default RBAC. The Manager will set fields on the Controller // and Start it when the Manager is Started. @@ -87,33 +85,64 @@ func newReconciler(mgr manager.Manager, params operator.Parameters) *ReconcileSt } func addWatches(mgr manager.Manager, c controller.Controller, r *ReconcileStackConfigPolicy) error { + m := r.params.NamespaceMatcher // watch for changes to StackConfigPolicy - if err := c.Watch(source.Kind(mgr.GetCache(), &policyv1alpha1.StackConfigPolicy{}, &handler.TypedEnqueueRequestForObject[*policyv1alpha1.StackConfigPolicy]{})); err != nil { + if err := c.Watch(watches.NamespacedKind(m, mgr.GetCache(), &policyv1alpha1.StackConfigPolicy{}, &handler.TypedEnqueueRequestForObject[*policyv1alpha1.StackConfigPolicy]{})); err != nil { return err } // watch for changes to Elasticsearch and reconcile all StackConfigPolicy - if err := c.Watch(source.Kind[client.Object](mgr.GetCache(), &esv1.Elasticsearch{}, reconcileRequestForAllPolicies(r.Client))); err != nil { + if err := c.Watch(watches.NamespacedKind[client.Object](m, mgr.GetCache(), &esv1.Elasticsearch{}, reconcileRequestForAllPolicies(r.Client))); err != nil { return err } // watch for changes to Kibana and reconcile all StackConfigPolicy - if err := c.Watch(source.Kind[client.Object](mgr.GetCache(), &kibanav1.Kibana{}, reconcileRequestForAllPolicies(r.Client))); err != nil { + if err := c.Watch(watches.NamespacedKind[client.Object](m, mgr.GetCache(), &kibanav1.Kibana{}, reconcileRequestForAllPolicies(r.Client))); err != nil { return err } // watch Secrets soft owned by StackConfigPolicy - if err := c.Watch(source.Kind(mgr.GetCache(), &corev1.Secret{}, reconcileRequestForSoftOwnerPolicy())); err != nil { + if err := c.Watch(watches.NamespacedKind(m, mgr.GetCache(), &corev1.Secret{}, reconcileRequestForSoftOwnerPolicy())); err != nil { return err } // watch dynamically referenced secrets - if err := c.Watch(source.Kind(mgr.GetCache(), &corev1.Secret{}, r.dynamicWatches.Secrets)); err != nil { + if err := c.Watch(watches.NamespacedKind(m, mgr.GetCache(), &corev1.Secret{}, r.dynamicWatches.Secrets)); err != nil { return err } // watch dynamically referenced ConfigMaps - return c.Watch(source.Kind(mgr.GetCache(), &corev1.ConfigMap{}, r.dynamicWatches.ConfigMaps)) + if err := c.Watch(watches.NamespacedKind(m, mgr.GetCache(), &corev1.ConfigMap{}, r.dynamicWatches.ConfigMaps)); err != nil { + return err + } + + return watches.WatchNamespaceFlipsMapped(c, m, namespaceFlipRequests(mgr.GetCache(), r.params.OperatorNamespace)) +} + +// namespaceFlipRequests returns a mapper translating a namespace match-state change into +// reconcile requests for the StackConfigPolicies affected by it: the ones living in the +// flipped namespace (the default flip behavior), and the ones living in the operator +// namespace, which target resources cluster-wide. The latter cannot rely on the default +// behavior: the operator namespace is always managed and never flips itself, so a scope +// change of a target namespace would otherwise never re-enqueue its policies — leaving +// their settings unapplied on scope-in and their status counting hidden clusters on +// scope-out — unless a target object event incidentally hit the ES/Kibana mappers. +func namespaceFlipRequests(cache cache.Cache, operatorNamespace string) func(context.Context, *corev1.Namespace) []reconcile.Request { + return func(ctx context.Context, ns *corev1.Namespace) []reconcile.Request { + var list policyv1alpha1.StackConfigPolicyList + // List **cluster-wide** from the cache (not the FilterClient): policies in the + // namespace being de-scoped would be hidden by the FilterClient. + if err := cache.List(ctx, &list); err != nil { + return nil + } + var reqs []reconcile.Request + for _, policy := range list.Items { + if policy.Namespace == ns.Name || policy.Namespace == operatorNamespace { + reqs = append(reqs, reconcile.Request{NamespacedName: k8s.ExtractNamespacedName(&policy)}) + } + } + return reqs + } } func reconcileRequestForSoftOwnerPolicy() handler.TypedEventHandler[*corev1.Secret, reconcile.Request] { @@ -178,6 +207,10 @@ type ReconcileStackConfigPolicy struct { // Reconcile reads that state of the cluster for a StackConfigPolicy object and makes changes based on the state read and what is // in the StackConfigPolicy.Spec. func (r *ReconcileStackConfigPolicy) Reconcile(ctx context.Context, request reconcile.Request) (reconcile.Result, error) { + if !r.params.NamespaceMatcher.Matches(request.Namespace) { + r.onNamespaceOutOfScope(request.NamespacedName) + return reconcile.Result{}, nil + } ctx = common.NewReconciliationContext(ctx, &r.iteration, r.params.Tracer, controllerName, "policy_name", request) defer common.LogReconciliationRun(ulog.FromContext(ctx))() defer tracing.EndContextTransaction(ctx) @@ -602,13 +635,16 @@ func (r *ReconcileStackConfigPolicy) updateStatus(ctx context.Context, scp polic return common.UpdateStatus(ctx, r.Client, &scp) } -func (r *ReconcileStackConfigPolicy) onDelete(ctx context.Context, obj types.NamespacedName) error { - defer tracing.Span(&ctx)() - // Remove dynamic watches on secrets +func (r *ReconcileStackConfigPolicy) onNamespaceOutOfScope(obj types.NamespacedName) { r.dynamicWatches.Secrets.RemoveHandlerForKey(additionalSecretMountsWatcherName(obj)) // Remove dynamic watches on variablesFrom sources r.dynamicWatches.Secrets.RemoveHandlerForKey(variableSourcesWatcherName(obj)) r.dynamicWatches.ConfigMaps.RemoveHandlerForKey(variableSourcesWatcherName(obj)) +} + +func (r *ReconcileStackConfigPolicy) onDelete(ctx context.Context, obj types.NamespacedName) error { + defer tracing.Span(&ctx)() + r.onNamespaceOutOfScope(obj) // Send empty resource type so that we reset/delete secrets for configured elasticsearch and kibana clusters return handleOrphanSoftOwnedSecrets(ctx, r.Client, obj, nil, nil, "") } diff --git a/pkg/controller/stackconfigpolicy/namespace_flip_test.go b/pkg/controller/stackconfigpolicy/namespace_flip_test.go new file mode 100644 index 00000000000..401bff5a1d2 --- /dev/null +++ b/pkg/controller/stackconfigpolicy/namespace_flip_test.go @@ -0,0 +1,94 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. + +package stackconfigpolicy + +import ( + "context" + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + policyv1alpha1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/stackconfigpolicy/v1alpha1" + cachemock "github.com/elastic/cloud-on-k8s/v3/pkg/utils/test/mock" +) + +func Test_namespaceFlipRequests(t *testing.T) { + policy := func(name, namespace string) policyv1alpha1.StackConfigPolicy { + return policyv1alpha1.StackConfigPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, + } + } + req := func(namespace, name string) reconcile.Request { + return reconcile.Request{NamespacedName: types.NamespacedName{Namespace: namespace, Name: name}} + } + + // "flipped" is the namespace whose match state just changed, "scoped" is a namespace + // that remains in scope, "operator-ns" is the operator namespace (always managed, so + // it can never be the flipped one). + tests := []struct { + name string + policies []policyv1alpha1.StackConfigPolicy + want []reconcile.Request + }{ + { + name: "policies living in the flipped namespace are enqueued", + policies: []policyv1alpha1.StackConfigPolicy{ + policy("local", "flipped"), + policy("elsewhere", "scoped"), + }, + want: []reconcile.Request{ + req("flipped", "local"), + }, + }, + { + name: "operator namespace policies are enqueued on any flip, they target cluster-wide", + policies: []policyv1alpha1.StackConfigPolicy{ + policy("global", "operator-ns"), + policy("elsewhere", "scoped"), + }, + want: []reconcile.Request{ + req("operator-ns", "global"), + }, + }, + { + name: "mixed: both the flipped namespace's and the operator namespace's policies are enqueued", + policies: []policyv1alpha1.StackConfigPolicy{ + policy("local", "flipped"), + policy("global", "operator-ns"), + policy("elsewhere", "scoped"), + }, + want: []reconcile.Request{ + req("flipped", "local"), + req("operator-ns", "global"), + }, + }, + { + name: "no policies", + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := cachemock.NewCache(t) + c.On("List", mock.Anything, mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + list := args.Get(1).(*policyv1alpha1.StackConfigPolicyList) //nolint:forcetypeassert + list.Items = append(list.Items, tt.policies...) + }).Return(nil) + + reqs := namespaceFlipRequests(c, "operator-ns")( + context.Background(), + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "flipped"}}, + ) + + require.ElementsMatch(t, tt.want, reqs) + }) + } +} diff --git a/pkg/controller/stackconfigpolicy/validation/webhook.go b/pkg/controller/stackconfigpolicy/validation/webhook.go index 219fad9e8c6..5ac58a71944 100644 --- a/pkg/controller/stackconfigpolicy/validation/webhook.go +++ b/pkg/controller/stackconfigpolicy/validation/webhook.go @@ -12,6 +12,7 @@ import ( policyv1alpha1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/stackconfigpolicy/v1alpha1" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/license" + "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/nsmatch" commonwebhook "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/webhook" ulog "github.com/elastic/cloud-on-k8s/v3/pkg/utils/log" ) @@ -21,8 +22,8 @@ var log = ulog.Log.WithName("stackconfigpolicy-validation") // RegisterWebhook registers the StackConfigPolicy validating webhook with the manager. // operatorNamespace is forwarded to Validate so the namespace-scoping rule is enforced at // admission time. -func RegisterWebhook(mgr ctrl.Manager, licenseChecker license.Checker, managedNamespaces []string, operatorNamespace string) { - v := commonwebhook.NewResourceValidator[*policyv1alpha1.StackConfigPolicy](licenseChecker, managedNamespaces, &webhookValidator{operatorNamespace: operatorNamespace}) +func RegisterWebhook(mgr ctrl.Manager, licenseChecker license.Checker, managedNamespaces []string, operatorNamespace string, m *nsmatch.NamespaceMatcher) { + v := commonwebhook.NewResourceValidator[*policyv1alpha1.StackConfigPolicy](licenseChecker, managedNamespaces, &webhookValidator{operatorNamespace: operatorNamespace}).WithNamespaceMatcher(m) wh := admission.WithValidator[*policyv1alpha1.StackConfigPolicy](mgr.GetScheme(), v) log.Info("Registering StackConfigPolicy validating webhook", "path", policyv1alpha1.WebhookPath) mgr.GetWebhookServer().Register(policyv1alpha1.WebhookPath, wh) diff --git a/pkg/controller/webhook/webhook_certificates_controller.go b/pkg/controller/webhook/webhook_certificates_controller.go index 9edbe88efe7..a19b27af382 100644 --- a/pkg/controller/webhook/webhook_certificates_controller.go +++ b/pkg/controller/webhook/webhook_certificates_controller.go @@ -22,6 +22,7 @@ import ( "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/certificates" + "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/nsmatch" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/reconciler" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/tracing" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/watches" @@ -98,7 +99,7 @@ func newReconciler(mgr manager.Manager, webhookParams Params, clientset kubernet } // Add adds a new Controller to mgr with r as the reconcile.Reconciler -func Add(mgr manager.Manager, webhookParams Params, clientset kubernetes.Interface, webhook AdmissionControllerInterface, tracer *apm.Tracer) error { +func Add(mgr manager.Manager, m *nsmatch.NamespaceMatcher, webhookParams Params, clientset kubernetes.Interface, webhook AdmissionControllerInterface, tracer *apm.Tracer) error { r := newReconciler(mgr, webhookParams, clientset, tracer) // Create a new controller c, err := controller.New(ControllerName, mgr, controller.Options{Reconciler: r}) @@ -111,7 +112,7 @@ func Add(mgr manager.Manager, webhookParams Params, clientset kubernetes.Interfa Name: webhookParams.SecretName, } - if err := c.Watch(source.Kind(mgr.GetCache(), &corev1.Secret{}, &watches.NamedWatch[*corev1.Secret]{ + if err := c.Watch(watches.NamespacedKind(m, mgr.GetCache(), &corev1.Secret{}, &watches.NamedWatch[*corev1.Secret]{ Name: "webhook-server-cert", Watched: []types.NamespacedName{secret}, Watcher: secret, @@ -123,6 +124,7 @@ func Add(mgr manager.Manager, webhookParams Params, clientset kubernetes.Interfa Name: webhookParams.Name, } + // ValidatingWebhookConfiguration (and MutatingWebhookConfiguration) are cluster-scoped resources (no namespace), so source.Kind is used directly. return c.Watch(source.Kind(mgr.GetCache(), webhook.getType(), &watches.NamedWatch[client.Object]{ Name: "validatingwebhookconfiguration", Watched: []types.NamespacedName{webhookConfiguration}, diff --git a/pkg/telemetry/telemetry.go b/pkg/telemetry/telemetry.go index 8ef47616f24..f096e37a852 100644 --- a/pkg/telemetry/telemetry.go +++ b/pkg/telemetry/telemetry.go @@ -31,6 +31,7 @@ import ( logstashv1alpha1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/logstash/v1alpha1" mapsv1alpha1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/maps/v1alpha1" policyv1alpha1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/stackconfigpolicy/v1alpha1" + "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/nsmatch" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/reconciler" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/stackmon/monitoring" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/tracing" @@ -65,10 +66,11 @@ func NewReporter( client client.Client, operatorNamespace string, managedNamespaces []string, + namespaceMatcher *nsmatch.NamespaceMatcher, telemetryInterval time.Duration, tracer *apm.Tracer, ) Reporter { - if len(managedNamespaces) == 0 { + if len(managedNamespaces) == 0 && !namespaceMatcher.SelectorEnabled() { // treat no managed namespaces as managing all namespaces, ie. set empty string for namespace filtering managedNamespaces = append(managedNamespaces, "") } @@ -78,6 +80,7 @@ func NewReporter( client: client, operatorNamespace: operatorNamespace, managedNamespaces: managedNamespaces, + namespaceMatcher: namespaceMatcher, telemetryInterval: telemetryInterval, tracer: tracer, } @@ -88,6 +91,7 @@ type Reporter struct { client k8s.Client operatorNamespace string managedNamespaces []string + namespaceMatcher *nsmatch.NamespaceMatcher telemetryInterval time.Duration tracer *apm.Tracer } @@ -113,7 +117,15 @@ func marshalTelemetry(ctx context.Context, info about.OperatorInfo, stats map[st }) } -func (r *Reporter) getResourceStats(ctx context.Context) (map[string]any, error) { +func (r *Reporter) getNamespaces() []string { + if r.namespaceMatcher.SelectorEnabled() { + return r.namespaceMatcher.MatchingNamespaces() + } + + return r.managedNamespaces +} + +func (r *Reporter) getResourceStats(ctx context.Context, namespaces []string) (map[string]any, error) { span, _ := apm.StartSpan(ctx, "get_resource_stats", tracing.SpanTypeApp) defer span.End() @@ -130,7 +142,7 @@ func (r *Reporter) getResourceStats(ctx context.Context) (map[string]any, error) logstashStats, aopStats, } { - key, statsPart, err := f(r.client, r.managedNamespaces) + key, statsPart, err := f(r.client, namespaces) if err != nil { return nil, err } @@ -146,7 +158,9 @@ func (r *Reporter) report(ctx context.Context) { log := ulog.FromContext(ctx) - stats, err := r.getResourceStats(ctx) + namespaces := r.getNamespaces() + + stats, err := r.getResourceStats(ctx, namespaces) if err != nil { log.Error(err, "failed to get resource stats") return @@ -164,7 +178,7 @@ func (r *Reporter) report(ctx context.Context) { return } - for _, ns := range r.managedNamespaces { + for _, ns := range namespaces { var kibanaList kbv1.KibanaList if err := r.client.List(ctx, &kibanaList, client.InNamespace(ns)); err != nil { log.Error(err, "failed to list Kibanas") diff --git a/pkg/telemetry/telemetry_test.go b/pkg/telemetry/telemetry_test.go index 59dbf5387e7..da196152653 100644 --- a/pkg/telemetry/telemetry_test.go +++ b/pkg/telemetry/telemetry_test.go @@ -13,6 +13,7 @@ import ( "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" "sigs.k8s.io/controller-runtime/pkg/client" "github.com/elastic/cloud-on-k8s/v3/pkg/about" @@ -28,6 +29,7 @@ import ( logstashv1alpha1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/logstash/v1alpha1" mapsv1alpha1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/maps/v1alpha1" policyv1alpha1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/stackconfigpolicy/v1alpha1" + "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/nsmatch" "github.com/elastic/cloud-on-k8s/v3/pkg/utils/k8s" ) @@ -143,14 +145,36 @@ func createKbAndSecret(name, namespace string, count int32) (kbv1.Kibana, corev1 } } -func TestNewReporter(t *testing.T) { +type fixtures struct { + Kibana1 kbv1.Kibana + Secret1 corev1.Secret + Kibana2 kbv1.Kibana + Secret2 corev1.Secret + Kibana3 kbv1.Kibana + Secret3 corev1.Secret + Kibana4 kbv1.Kibana + Secret4 corev1.Secret +} + +func initFakeClient() (k8s.Client, fixtures) { kb1, s1 := createKbAndSecret("kb1", "ns1", 1) kb2, s2 := createKbAndSecret("kb2", "ns2", 2) kb3, s3 := createKbAndSecret("kb3", "ns3", 3) kb4, s4 := createKbAndSecret("kb4", "ns2", 1) kb4.Labels = map[string]string{"helm.sh/chart": "eck-kibana-0.1.0"} - client := k8s.NewFakeClient( + fx := fixtures{ + Kibana1: kb1, + Secret1: s1, + Kibana2: kb2, + Secret2: s2, + Kibana3: kb3, + Secret3: s3, + Kibana4: kb4, + Secret4: s4, + } + + return k8s.NewFakeClient( &kb1, &kb2, &kb3, @@ -443,14 +467,21 @@ func TestNewReporter(t *testing.T) { Resources: 2, }, }, - ) + ), fx +} + +func TestNewReporter(t *testing.T) { + const operatorNamespace = "elastic-system" - // We only want the reporter to handle the managed namespaces, in this test only ns1 and ns2 are managed. - r := NewReporter(testOperatorInfo, client, "elastic-system", []string{kb1.Namespace, kb2.Namespace}, 1*time.Hour, nil) - r.report(context.Background()) + t.Run("managed namespaces", func(t *testing.T) { + client, fx := initFakeClient() - wantData := map[string][]byte{ - "telemetry.yml": []byte(`eck: + // We only want the reporter to handle the managed namespaces, in this test only ns1 and ns2 are managed. + r := NewReporter(testOperatorInfo, client, operatorNamespace, []string{fx.Kibana1.Namespace, fx.Kibana2.Namespace}, nil, 1*time.Hour, nil) + r.report(context.Background()) + + wantData := map[string][]byte{ + "telemetry.yml": []byte(`eck: build: date: "2019-09-20T07:00:00Z" hash: b5316231 @@ -533,18 +564,125 @@ func TestNewReporter(t *testing.T) { snapshot_lifecycle_policies_count: 3 snapshot_repositories_count: 2 `), - } + } + + require.NoError(t, client.Get(context.Background(), k8s.ExtractNamespacedName(&fx.Secret1), &fx.Secret1)) + assertSameSecretContent(t, wantData, fx.Secret1.Data) + + // We expect Kibana instances in s1 and s2 to have the same content. + require.NoError(t, client.Get(context.Background(), k8s.ExtractNamespacedName(&fx.Secret2), &fx.Secret2)) + assertSameSecretContent(t, wantData, fx.Secret2.Data) - require.NoError(t, client.Get(context.Background(), k8s.ExtractNamespacedName(&s1), &s1)) - assertSameSecretContent(t, wantData, s1.Data) + // No data expected in s3 since it is not a managed namespace. + require.NoError(t, client.Get(context.Background(), k8s.ExtractNamespacedName(&fx.Secret3), &fx.Secret3)) + require.Nil(t, fx.Secret3.Data) + }) - // We expect Kibana instances in s1 and s2 to have the same content. - require.NoError(t, client.Get(context.Background(), k8s.ExtractNamespacedName(&s2), &s2)) - assertSameSecretContent(t, wantData, s2.Data) + t.Run("dynamic namespaces", func(t *testing.T) { + client, fx := initFakeClient() - // No data expected in s3 since it is not a managed namespace. - require.NoError(t, client.Get(context.Background(), k8s.ExtractNamespacedName(&s3), &s3)) - require.Nil(t, s3.Data) + sel := mustLabelSelector(t, map[string]string{"env": "prod1"}) + nm := matcherObserving(sel, operatorNamespace, + nsLabelled("ns1", map[string]string{"env": "prod1"}), + nsLabelled("ns2", map[string]string{"env": "prod2"}), + nsLabelled("ns3", map[string]string{"env": "prod1"}), + ) + r := NewReporter(testOperatorInfo, client, operatorNamespace, nil, nm, 1*time.Hour, nil) + r.report(context.Background()) + + wantData := map[string][]byte{ + "telemetry.yml": []byte(`eck: + build: + date: "2019-09-20T07:00:00Z" + hash: b5316231 + snapshot: "true" + version: 1.1.0 + custom_operator_namespace: true + distribution: v1.16.13-gke.1 + distributionChannel: test-channel + license: + eck_license_level: basic + enterprise_resource_units: "1" + total_managed_memory: 3.22GB + operator_uuid: 15039433-f873-41bd-b6e7-10ee3665cafa + stats: + agents: + helm_resource_count: 0 + multiple_refs: 0 + pod_count: 0 + resource_count: 0 + apms: + helm_resource_count: 1 + pod_count: 2 + resource_count: 1 + autoopsagentpolicies: + helm_resource_count: 1 + pod_count: 2 + resource_count: 1 + beats: + auditbeat_count: 0 + filebeat_count: 1 + heartbeat_count: 0 + helm_resource_count: 1 + journalbeat_count: 0 + metricbeat_count: 1 + packetbeat_count: 0 + pod_count: 14 + resource_count: 2 + elasticsearches: + autoscaled_resource_count: 2 + helm_resource_count: 1 + pod_count: 10 + remote_clusters_api_keys_count: 0 + remote_clusters_count: 0 + resource_count: 4 + stack_monitoring_logs_count: 1 + stack_monitoring_metrics_count: 1 + enterprisesearches: + helm_resource_count: 1 + pod_count: 3 + resource_count: 1 + kibanas: + helm_resource_count: 0 + pod_count: 0 + resource_count: 2 + logstashes: + helm_resource_count: 1 + pipeline_count: 1 + pipeline_ref_count: 0 + pod_count: 3 + resource_count: 1 + service_count: 2 + stack_monitoring_logs_count: 1 + stack_monitoring_metrics_count: 1 + maps: + pod_count: 1 + resource_count: 1 + stackconfigpolicies: + configured_resources_count: 10 + resource_count: 1 + settings: + cluster_settings_count: 1 + component_templates_count: 0 + composable_index_templates_count: 0 + index_lifecycle_policies_count: 0 + ingest_pipelines_count: 0 + role_mappings_count: 0 + roles_count: 0 + snapshot_lifecycle_policies_count: 2 + snapshot_repositories_count: 1 +`), + } + + require.NoError(t, client.Get(context.Background(), k8s.ExtractNamespacedName(&fx.Secret1), &fx.Secret1)) + assertSameSecretContent(t, wantData, fx.Secret1.Data) + + require.NoError(t, client.Get(context.Background(), k8s.ExtractNamespacedName(&fx.Secret2), &fx.Secret2)) + require.Nil(t, fx.Secret2.Data) + + require.NoError(t, client.Get(context.Background(), k8s.ExtractNamespacedName(&fx.Secret3), &fx.Secret3)) + assertSameSecretContent(t, wantData, fx.Secret3.Data) + }) } // assertSameSecretContent compares 2 data secrets and print a human friendly diff if not equal. @@ -668,7 +806,8 @@ func TestReporter_report(t *testing.T) { RemoteClustersAPIKeysCount: 0, }, }, - }, { + }, + { name: "With downward API and one label", fields: fields{ objects: []client.Object{ @@ -707,7 +846,8 @@ func TestReporter_report(t *testing.T) { }, }, }, - }, { + }, + { name: "With downward API and several labels", fields: fields{ objects: []client.Object{ @@ -830,3 +970,113 @@ func TestReporter_report(t *testing.T) { }) } } + +func nsLabelled(name string, lbls map[string]string) corev1.Namespace { + return corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: name, Labels: lbls}} +} + +func mustLabelSelector(t *testing.T, matchLabels map[string]string) labels.Selector { + t.Helper() + sel, err := metav1.LabelSelectorAsSelector(&metav1.LabelSelector{MatchLabels: matchLabels}) + require.NoError(t, err) + return sel +} + +// matcherObserving returns a NamespaceMatcher for operatorNS that has observed +// the given namespaces, so its in-memory match state reflects their labels. +func matcherObserving(sel labels.Selector, operatorNS string, nss ...corev1.Namespace) *nsmatch.NamespaceMatcher { + m := nsmatch.NewNamespaceMatcher(sel, operatorNS) + for i := range nss { + m.ObserveNamespace(&nss[i]) + } + return m +} + +func TestGetNamespaces(t *testing.T) { + managed := []string{"ns1", "ns2"} + sel := mustLabelSelector(t, map[string]string{"env": "prod"}) + + for _, tt := range []struct { + name string + setup func(t *testing.T) Reporter + wantNSes []string + }{ + { + name: "nil namespaceMatcher returns managedNamespaces", + setup: func(t *testing.T) Reporter { + t.Helper() + return Reporter{managedNamespaces: managed} + }, + wantNSes: managed, + }, + { + name: "disabled selector returns managedNamespaces", + setup: func(t *testing.T) Reporter { + t.Helper() + return Reporter{ + managedNamespaces: managed, + namespaceMatcher: nsmatch.NewNamespaceMatcher(nil, "elastic-system"), + } + }, + wantNSes: managed, + }, + { + name: "enabled selector with all namespaces matching", + setup: func(t *testing.T) Reporter { + t.Helper() + nm := matcherObserving(sel, "elastic-system", + nsLabelled("prod-a", map[string]string{"env": "prod"}), + nsLabelled("prod-b", map[string]string{"env": "prod"}), + nsLabelled("elastic-system", nil), + ) + return Reporter{namespaceMatcher: nm} + }, + wantNSes: []string{"prod-a", "prod-b", "elastic-system"}, + }, + { + name: "enabled selector with only some namespaces matching", + setup: func(t *testing.T) Reporter { + t.Helper() + nm := matcherObserving(sel, "elastic-system", + nsLabelled("prod-ns", map[string]string{"env": "prod"}), + nsLabelled("dev-ns", map[string]string{"env": "dev"}), + nsLabelled("elastic-system", nil), + ) + return Reporter{namespaceMatcher: nm} + }, + wantNSes: []string{"prod-ns", "elastic-system"}, + }, + { + name: "enabled selector with no matching namespaces", + setup: func(t *testing.T) Reporter { + t.Helper() + nm := matcherObserving(sel, "elastic-system", + nsLabelled("dev-ns", map[string]string{"env": "dev"}), + nsLabelled("elastic-system", nil), + ) + return Reporter{namespaceMatcher: nm} + }, + wantNSes: []string{"elastic-system"}, + }, + { + name: "namespace that stopped matching is excluded", + setup: func(t *testing.T) Reporter { + t.Helper() + nm := matcherObserving(sel, "elastic-system", + nsLabelled("prod-ns", map[string]string{"env": "prod"}), + ) + // prod-ns loses its matching label. + flipped := nsLabelled("prod-ns", map[string]string{"env": "dev"}) + nm.ObserveNamespace(&flipped) + return Reporter{namespaceMatcher: nm} + }, + wantNSes: []string{"elastic-system"}, + }, + } { + t.Run(tt.name, func(t *testing.T) { + r := tt.setup(t) + got := r.getNamespaces() + require.ElementsMatch(t, tt.wantNSes, got) + }) + } +} diff --git a/pkg/utils/test/mock/mocks.go b/pkg/utils/test/mock/mocks.go new file mode 100644 index 00000000000..845a98c0974 --- /dev/null +++ b/pkg/utils/test/mock/mocks.go @@ -0,0 +1,60 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. + +package mock + +import ( + "context" + "testing" + + "github.com/stretchr/testify/mock" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/cache" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// Cache stubs List and Get on cache.Cache via testify/mock. +type Cache struct { + mock.Mock + cache.Cache +} + +func (m *Cache) 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 *Cache) List(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { + args := m.Called(ctx, list, opts) + return args.Error(0) +} + +// OnListSetNamespaceList sets up a List expectation that populates the NamespaceList with namespaces. +func (m *Cache) OnListSetNamespaceList(namespaces ...corev1.Namespace) *mock.Call { + return m.On("List", mock.Anything, mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { + args.Get(1).(*corev1.NamespaceList).Items = namespaces //nolint:forcetypeassert + }) +} + +// OnGetSetNamespace sets up a Get expectation that populates obj as a Namespace with the given labels. +func (m *Cache) OnGetSetNamespace(lbls map[string]string) *mock.Call { + return m.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { + key := args.Get(1).(client.ObjectKey) //nolint:forcetypeassert + ns := args.Get(2).(*corev1.Namespace) //nolint:forcetypeassert + ns.Name = key.Name + ns.Labels = lbls + ns.ObjectMeta = metav1.ObjectMeta{Name: key.Name, Labels: lbls} + }) +} + +// newMockCache creates a mockCache and registers AssertExpectations as a test cleanup. +func NewCache(t *testing.T) *Cache { + t.Helper() + m := &Cache{} + t.Cleanup(func() { m.AssertExpectations(t) }) + return m +} diff --git a/test/e2e/global_ca_test.go b/test/e2e/global_ca_test.go index 8434ba0a51a..77f132c0f0a 100644 --- a/test/e2e/global_ca_test.go +++ b/test/e2e/global_ca_test.go @@ -13,23 +13,21 @@ import ( "testing" "time" - "github.com/ghodss/yaml" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" commonv1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/common/v1" logstashv1alpha1 "github.com/elastic/cloud-on-k8s/v3/pkg/apis/logstash/v1alpha1" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/certificates" "github.com/elastic/cloud-on-k8s/v3/pkg/controller/common/reconciler" - "github.com/elastic/cloud-on-k8s/v3/pkg/utils/k8s" e2e_agent "github.com/elastic/cloud-on-k8s/v3/test/e2e/agent" "github.com/elastic/cloud-on-k8s/v3/test/e2e/test" elasticagent "github.com/elastic/cloud-on-k8s/v3/test/e2e/test/agent" "github.com/elastic/cloud-on-k8s/v3/test/e2e/test/beat" "github.com/elastic/cloud-on-k8s/v3/test/e2e/test/elasticsearch" "github.com/elastic/cloud-on-k8s/v3/test/e2e/test/enterprisesearch" + "github.com/elastic/cloud-on-k8s/v3/test/e2e/test/helper" "github.com/elastic/cloud-on-k8s/v3/test/e2e/test/kibana" "github.com/elastic/cloud-on-k8s/v3/test/e2e/test/logstash" ) @@ -90,7 +88,7 @@ func TestGlobalCA(t *testing.T) { _, err = reconciler.ReconcileSecret(context.Background(), k.Client, secret, nil) require.NoError(t, err) // reconfigure the operator to use the CA - require.NoError(t, addToOperatorConfig(k.Client, "ca", "/tmp/ca-certs")) + require.NoError(t, helper.AddToOperatorConfig(k.Client, "ca", "/tmp/ca-certs")) // keep track of operator restarts var restartCount int32 @@ -109,20 +107,20 @@ func TestGlobalCA(t *testing.T) { { Name: "retrieve current operator restart count", Test: func(t *testing.T) { - restartCount, err = operatorRestartCount(k) + restartCount, err = helper.OperatorRestartCount(k) require.NoError(t, err) }, }, { Name: "reset operator to use self-signed certificates per resource", Test: func(t *testing.T) { - require.NoError(t, removeFromOperatorConfig(k.Client, "ca")) + require.NoError(t, helper.RemoveFromOperatorConfig(k.Client, "ca")) }, }, { Name: "assert operator has been restarted once more", Test: test.Eventually(func() error { - newCount, err := operatorRestartCount(k) + newCount, err := helper.OperatorRestartCount(k) if err != nil { return err } @@ -149,55 +147,6 @@ func TestGlobalCA(t *testing.T) { test.RunMutations(t, []test.Builder{es, kb, ent, agent, ls, testPod}, []test.Builder{esUpd}) } -func removeFromOperatorConfig(k k8s.Client, key string) error { - return updateOperatorConfig(k, func(cfg map[string]interface{}) { - delete(cfg, key) - }) -} - -func addToOperatorConfig(k k8s.Client, key, value string) error { - return updateOperatorConfig(k, func(cfg map[string]interface{}) { - cfg[key] = value - }) -} - -func updateOperatorConfig(k k8s.Client, f func(map[string]interface{})) error { - var cm corev1.ConfigMap - if err := k.Get(context.Background(), - types.NamespacedName{Name: fmt.Sprintf("%s-operator", test.Ctx().TestRun), Namespace: test.Ctx().Operator.Namespace}, - &cm, - ); err != nil { - return err - } - raw := cm.Data["eck.yaml"] - config := map[string]interface{}{} - if err := yaml.Unmarshal([]byte(raw), &config); err != nil { - return err - } - f(config) - bytes, err := yaml.Marshal(config) - if err != nil { - return err - } - cm.Data["eck.yaml"] = string(bytes) - return k.Update(context.Background(), &cm) -} - -func operatorRestartCount(k *test.K8sClient) (int32, error) { - pods, err := k.GetPods(test.OperatorPodListOptions(test.Ctx().Operator.Namespace)...) - if err != nil { - return 0, err - } - for _, p := range pods { - for _, c := range p.Status.ContainerStatuses { - if c.Name == "manager" { - return c.RestartCount, nil - } - } - } - return 0, fmt.Errorf("could not find operator container") -} - func operatorSecretForCA( ca *certificates.CA, ) (corev1.Secret, error) { diff --git a/test/e2e/namespace_selector/namespace_selector_test.go b/test/e2e/namespace_selector/namespace_selector_test.go new file mode 100644 index 00000000000..e43874cb2fe --- /dev/null +++ b/test/e2e/namespace_selector/namespace_selector_test.go @@ -0,0 +1,167 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. + +//go:build mixed || e2e + +package namespace_selector + +import ( + "errors" + "fmt" + "os" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/elastic/cloud-on-k8s/v3/test/e2e/test" + "github.com/elastic/cloud-on-k8s/v3/test/e2e/test/elasticsearch" + "github.com/elastic/cloud-on-k8s/v3/test/e2e/test/helper" +) + +// TestNamespaceSelectorDynamicLabelChange verifies that the operator dynamically picks up a namespace +// when it gains the label matched by the namespace selector, without requiring an operator restart. +// +// The test requires an enterprise license. It labels only ns1 at startup, configures the operator +// with a matchLabels selector for "eck-visible=true", waits for the restart, confirms ES in ns1 is +// reconciled and ES in ns2 is ignored, then adds the label to ns2 and asserts the operator reconciles +// ES in ns2 without restarting. +// +// NOTE: this test mutates global operator configuration and must not run in parallel +// with other tests in the same test run. +func TestNamespaceSelectorDynamicLabelChange(t *testing.T) { + if test.Ctx().TestLicense == "" { + t.SkipNow() + } + + k := test.NewK8sClientOrFatal() + ns1 := test.Ctx().ManagedNamespace(0) + ns2 := test.Ctx().ManagedNamespace(1) + + const eckVisibleLabel = "eck-visible" + + licenseBytes, err := os.ReadFile(test.Ctx().TestLicense) + require.NoError(t, err) + + esNs1 := elasticsearch.NewBuilder("ns-sel-dyn"). + WithNamespace(ns1). + WithESMasterDataNodes(1, elasticsearch.DefaultResources) + esNs2 := elasticsearch.NewBuilder("ns-sel-dyn-ns2"). + WithNamespace(ns2). + WithESMasterDataNodes(1, elasticsearch.DefaultResources) + + licenseTestContext := elasticsearch.NewLicenseTestContext(k, esNs1.Elasticsearch) + + originalNamespaces, err := helper.GetOperatorConfigValue(k.Client, "namespaces") + require.NoError(t, err) + + var restartCount int32 + + // Always restore namespace labels and operator config on exit, even on test failure. + t.Cleanup(func() { + if err := helper.DeleteNamespaceLabel(t.Context(), k.Client, eckVisibleLabel, ns1, ns2); err != nil { + t.Logf("WARNING: failed to delete namespaces labels: %s", err.Error()) + } + if err := helper.UpdateOperatorConfig(k.Client, func(cfg map[string]any) { + delete(cfg, "namespace-selector") + if originalNamespaces != nil { + cfg["namespaces"] = originalNamespaces + } + }); err != nil { + t.Logf("WARNING: failed to restore operator config: %v", err) + return + } + test.Eventually(func() error { + newCount, err := helper.OperatorRestartCount(k) + if err != nil { + return err + } + if newCount <= restartCount { + return errors.New("waiting to restart after config restore") + } + return nil + })(t) + }) + + test.StepList{}. + WithStep(licenseTestContext.DeleteAllEnterpriseLicenseSecrets()). + WithStep(licenseTestContext.CreateEnterpriseLicenseSecret("eck-license-ns-sel-dynamic", licenseBytes)). + WithStep(test.Step{ + Name: "add eck-visible=true label to ns1; ns2 remains unlabeled", + Test: func(t *testing.T) { + require.NoError(t, helper.SetNamespaceLabel(t.Context(), k.Client, ns1, eckVisibleLabel, "true")) + }, + }). + WithStep(test.Step{ + Name: "record baseline operator restart count", + Test: func(t *testing.T) { + restartCount, err = helper.OperatorRestartCount(k) + require.NoError(t, err) + }, + }). + WithStep(test.Step{ + Name: "switch operator to eck-visible=true namespace-selector", + Test: func(t *testing.T) { + require.NoError(t, helper.UpdateOperatorConfig(k.Client, func(cfg map[string]any) { + delete(cfg, "namespaces") + cfg["namespace-selector"] = map[string]any{ + "matchLabels": map[string]any{ + eckVisibleLabel: "true", + }, + } + })) + }, + }). + WithStep(test.Step{ + Name: "wait for operator restart with new namespace-selector config", + Test: test.Eventually(func() error { + newCount, err := helper.OperatorRestartCount(k) + if err != nil { + return err + } + if newCount <= restartCount { + return fmt.Errorf("waiting for operator restart after namespace-selector config change (current restarts: %d)", newCount) + } + return nil + }), + }). + WithStep(test.Step{ + Name: "record post-restart count as the no-restart baseline", + Test: func(t *testing.T) { + restartCount, err = helper.OperatorRestartCount(k) + require.NoError(t, err) + }, + }). + WithSteps(esNs1.InitTestSteps(k)). + WithSteps(esNs1.CreationTestSteps(k)). + WithSteps(test.CheckTestSteps(esNs1, k)). + WithSteps(esNs2.InitTestSteps(k)). + WithSteps(esNs2.CreationTestSteps(k)). // create the CRD but don't attempt to verify the cluster + WithStep(test.Step{ + Name: "verify operator does not reconcile ES in the unlabeled ns2", + Test: func(t *testing.T) { + time.Sleep(30 * time.Second) + require.NoError(t, k.CheckPodCount(0, test.ESPodListOptions(ns2, esNs2.Elasticsearch.Name)...)) + }, + }). + WithStep(test.Step{ + Name: "add eck-visible=true to ns2 to trigger dynamic namespace pickup", + Test: func(t *testing.T) { + require.NoError(t, helper.SetNamespaceLabel(t.Context(), k.Client, ns2, eckVisibleLabel, "true")) + }, + }). + WithSteps(test.CheckTestSteps(esNs2, k)). + WithStep(test.Step{ + Name: "assert operator did not restart to pick up the newly labeled namespace", + Test: func(t *testing.T) { + postLabelRestartCount, err := helper.OperatorRestartCount(k) + require.NoError(t, err) + require.Equal(t, restartCount, postLabelRestartCount, "operator must not restart when a namespace gains the selector label") + }, + }). + WithSteps(esNs1.DeletionTestSteps(k)). + WithSteps(esNs2.DeletionTestSteps(k)). + WithStep(licenseTestContext.DeleteAllEnterpriseLicenseSecrets()). + RunSequential(t) +} diff --git a/test/e2e/test/helper/config.go b/test/e2e/test/helper/config.go new file mode 100644 index 00000000000..3948e0cd5a2 --- /dev/null +++ b/test/e2e/test/helper/config.go @@ -0,0 +1,76 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. + +//go:build mixed || e2e + +package helper + +import ( + "context" + "fmt" + + "github.com/ghodss/yaml" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + + "github.com/elastic/cloud-on-k8s/v3/pkg/utils/k8s" + "github.com/elastic/cloud-on-k8s/v3/test/e2e/test" +) + +func RemoveFromOperatorConfig(k k8s.Client, key string) error { + return UpdateOperatorConfig(k, func(cfg map[string]any) { + delete(cfg, key) + }) +} + +func AddToOperatorConfig(k k8s.Client, key, value string) error { + return UpdateOperatorConfig(k, func(cfg map[string]any) { + cfg[key] = value + }) +} + +func UpdateOperatorConfig(k k8s.Client, f func(map[string]any)) error { + var cm corev1.ConfigMap + if err := k.Get(context.Background(), + types.NamespacedName{Name: fmt.Sprintf("%s-operator", test.Ctx().TestRun), Namespace: test.Ctx().Operator.Namespace}, + &cm, + ); err != nil { + return err + } + raw := cm.Data["eck.yaml"] + config := map[string]any{} + if err := yaml.Unmarshal([]byte(raw), &config); err != nil { + return err + } + f(config) + bytes, err := yaml.Marshal(config) + if err != nil { + return err + } + cm.Data["eck.yaml"] = string(bytes) + return k.Update(context.Background(), &cm) +} + +func GetOperatorConfigValue(k k8s.Client, key string) (any, error) { + var result any + err := UpdateOperatorConfig(k, func(cfg map[string]any) { + result = cfg[key] + }) + return result, err +} + +func OperatorRestartCount(k *test.K8sClient) (int32, error) { + pods, err := k.GetPods(test.OperatorPodListOptions(test.Ctx().Operator.Namespace)...) + if err != nil { + return 0, err + } + for _, p := range pods { + for _, c := range p.Status.ContainerStatuses { + if c.Name == "manager" { + return c.RestartCount, nil + } + } + } + return 0, fmt.Errorf("could not find operator container") +} diff --git a/test/e2e/test/helper/namespace.go b/test/e2e/test/helper/namespace.go new file mode 100644 index 00000000000..b7bd1b41fd9 --- /dev/null +++ b/test/e2e/test/helper/namespace.go @@ -0,0 +1,43 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. + +//go:build mixed || e2e + +package helper + +import ( + "context" + "errors" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + + "github.com/elastic/cloud-on-k8s/v3/pkg/utils/k8s" +) + +// SetNamespaceLabel sets the given label on the namespace identified by name. +func SetNamespaceLabel(ctx context.Context, k k8s.Client, name, key, value string) error { + var ns corev1.Namespace + if err := k.Get(context.Background(), types.NamespacedName{Name: name}, &ns); err != nil { + return err + } + if ns.Labels == nil { + ns.Labels = make(map[string]string) + } + ns.Labels[key] = value + return k.Update(ctx, &ns) +} + +func DeleteNamespaceLabel(ctx context.Context, k k8s.Client, labelName string, namespacesNames ...string) error { + errs := make([]error, 0, len(namespacesNames)) + for _, nsName := range namespacesNames { + var ns corev1.Namespace + if err := k.Get(context.Background(), types.NamespacedName{Name: nsName}, &ns); err == nil { + delete(ns.Labels, labelName) + errs = append(errs, k.Update(context.Background(), &ns)) + } + } + + return errors.Join(errs...) +}