From d11d2ab7621d8ade2468f70c3e29d39837657b41 Mon Sep 17 00:00:00 2001 From: dabrign Date: Fri, 7 Mar 2025 15:54:19 +0100 Subject: [PATCH 01/22] Initial commit --- .gitignore | 21 ++ LICENSE | 674 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 695 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d01bd1a --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Generated by Cargo +# will have compiled files and executables +debug/ +target/ + +# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html +Cargo.lock + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +# RustRover +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. From e79a58c2aa10dbccc6f91f74d00c70a04a2c8f3c Mon Sep 17 00:00:00 2001 From: Danilo Date: Fri, 7 Mar 2025 17:24:22 +0100 Subject: [PATCH 02/22] feat: bootstrap --- .gitignore | 7 +++- Cargo.toml | 29 +++++++++++++ Dockerfile | 13 ++++++ README.md | 79 ++++++++++++++++++++++++++++++++++++ manifests/crd.yaml | 77 +++++++++++++++++++++++++++++++++++ manifests/crd_2.yaml | 74 +++++++++++++++++++++++++++++++++ manifests/deployment.yaml | 60 +++++++++++++++++++++++++++ manifests/example-cr.yaml | 7 ++++ src/bin/generate_crd.rs | 25 ++++++++++++ src/controller/mod.rs | 1 + src/controller/reconciler.rs | 79 ++++++++++++++++++++++++++++++++++++ src/crd/mod.rs | 1 + src/crd/sonarr.rs | 41 +++++++++++++++++++ src/error/mod.rs | 14 +++++++ src/lib.rs | 6 +++ src/main.rs | 68 +++++++++++++++++++++++++++++++ 16 files changed, 580 insertions(+), 1 deletion(-) create mode 100644 Cargo.toml create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 manifests/crd.yaml create mode 100644 manifests/crd_2.yaml create mode 100644 manifests/deployment.yaml create mode 100644 manifests/example-cr.yaml create mode 100644 src/bin/generate_crd.rs create mode 100644 src/controller/mod.rs create mode 100644 src/controller/reconciler.rs create mode 100644 src/crd/mod.rs create mode 100644 src/crd/sonarr.rs create mode 100644 src/error/mod.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs diff --git a/.gitignore b/.gitignore index d01bd1a..0eb69bb 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,9 @@ Cargo.lock # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ \ No newline at end of file +#.idea/ + +# Added by cargo + +/target + diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..261dd54 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "k8s-opeartor-sonarr" +version = "0.1.0" +edition = "2021" + +[dependencies] +kube = { version = "0.84.0", features = ["runtime", "derive"] } +k8s-openapi = { version = "0.18.0", features = ["v1_26"] } +tokio = { version = "1.29.1", features = ["full"] } +futures = "0.3.28" +anyhow = "1.0.72" +thiserror = "1.0.44" +serde = { version = "1.0.174", features = ["derive"] } +serde_json = "1.0.103" +serde_yaml = "0.9.25" +tracing = "0.1.37" +tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } +chrono = { version = "0.4.26", features = ["serde"] } +async-trait = "0.1.71" +clap = { version = "4.3.19", features = ["derive"] } +schemars = "0.8.12" + +[[bin]] +name = "k8s-opeartor-sonarr" +path = "src/main.rs" + +[[bin]] +name = "generate_crd" +path = "src/bin/generate_crd.rs" \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2d43fce --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM rust:1.71 as builder + +WORKDIR /app +COPY . . +RUN cargo build --release + +FROM debian:bullseye-slim + +RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /app/target/release/rust-k8s-operator /usr/local/bin/ + +ENTRYPOINT ["k8s-opeartor-sonarr"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..45b5659 --- /dev/null +++ b/README.md @@ -0,0 +1,79 @@ +# Rust Kubernetes Operator + +A Kubernetes operator written in Rust using the kube-rs library. + +## Prerequisites + +- Rust 1.71+ +- Kubernetes cluster (local or remote) +- kubectl configured to access your cluster + +## Building and Running + +### Local Development + +```bash +# Build the project +cargo build + +# Run the operator locally +cargo run + +# Run with a specific namespace +cargo run -- --namespace=default +``` + +### Running in Kubernetes + +```bash +# Apply the CRD +kubectl apply -f manifests/crd.yaml + +# Build and push the Docker image +docker build -t your-registry/rust-k8s-operator:latest . +docker push your-registry/rust-k8s-operator:latest + +# Deploy the operator (update the image reference in the deployment manifest) +kubectl apply -f manifests/deployment.yaml + +# Create an example resource +kubectl apply -f manifests/example-cr.yaml +``` + + +## CRD Generation + +This project uses code-first CRD definition. The CRD YAML is generated from the Rust code to ensure they stay in sync. + +### Generating the CRD + +```bash +# Using cargo directly +cargo run --bin generate_crd + +# Or using the provided shell script +./generate-crd.sh + +# Generate and apply to the cluster +./generate-crd.sh --apply +``` + +The generated CRD will be saved to `manifests/crd.yaml`. + +### How it works + +The CRD is defined in the Rust code using the `#[derive(CustomResource)]` macro from the kube-rs crate. The `generate_crd` binary uses the `CustomResourceExt` trait to generate the YAML representation of the CRD. + +If you modify the CRD definition in the Rust code, make sure to regenerate the YAML manifest. + +## Project Structure + +- `src/main.rs`: Entry point for the operator +- `src/crd/`: Custom resource definitions +- `src/controller/`: Reconciliation logic +- `src/error/`: Error types +- `manifests/`: Kubernetes manifests for deployment + +## License + +MIT \ No newline at end of file diff --git a/manifests/crd.yaml b/manifests/crd.yaml new file mode 100644 index 0000000..18fa695 --- /dev/null +++ b/manifests/crd.yaml @@ -0,0 +1,77 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: myresources.example.com +spec: + group: example.com + names: + kind: MyResource + plural: myresources + singular: myresource + shortNames: + - mr + scope: Namespaced + versions: + - name: v1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + required: + - spec + properties: + spec: + type: object + required: + - message + properties: + message: + type: string + description: A message to display + replicaCount: + type: integer + description: Number of replicas + minimum: 0 + status: + type: object + properties: + createdAt: + type: string + format: date-time + conditions: + type: array + items: + type: object + required: + - type + - status + - lastTransitionTime + - reason + - message + properties: + type: + type: string + status: + type: string + lastTransitionTime: + type: string + format: date-time + reason: + type: string + message: + type: string + observedGeneration: + type: integer + format: int64 + ready: + type: boolean + subresources: + status: {} + additionalPrinterColumns: + - name: Ready + type: boolean + jsonPath: .status.ready + - name: Age + type: date + jsonPath: .metadata.creationTimestamp diff --git a/manifests/crd_2.yaml b/manifests/crd_2.yaml new file mode 100644 index 0000000..44cc0b5 --- /dev/null +++ b/manifests/crd_2.yaml @@ -0,0 +1,74 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: sonarrs.devopsarr.io +spec: + group: devopsarr.io + names: + categories: [] + kind: Sonarr + plural: sonarrs + shortNames: + - son + singular: sonarr + scope: Namespaced + versions: + - additionalPrinterColumns: [] + name: v1 + schema: + openAPIV3Schema: + description: Auto-generated derived type for SonarrSpec via `CustomResource` + properties: + spec: + description: Define the Custom Resource Definition (CRD) for our operator + properties: + message: + type: string + replica_count: + format: int32 + nullable: true + type: integer + required: + - message + type: object + status: + description: Status object for our CRD + nullable: true + properties: + conditions: + items: + description: Condition type for tracking the status of the resource + properties: + message: + type: string + reason: + type: string + status: + type: string + type: + type: string + required: + - message + - reason + - status + - type + type: object + type: array + observed_generation: + format: int64 + nullable: true + type: integer + ready: + nullable: true + type: boolean + required: + - conditions + type: object + required: + - spec + title: Sonarr + type: object + served: true + storage: true + subresources: + status: {} diff --git a/manifests/deployment.yaml b/manifests/deployment.yaml new file mode 100644 index 0000000..8656776 --- /dev/null +++ b/manifests/deployment.yaml @@ -0,0 +1,60 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: rust-k8s-operator + labels: + app: rust-k8s-operator +spec: + replicas: 1 + selector: + matchLabels: + app: rust-k8s-operator + template: + metadata: + labels: + app: rust-k8s-operator + spec: + serviceAccountName: rust-k8s-operator + containers: + - name: operator + image: your-registry/rust-k8s-operator:latest + resources: + limits: + cpu: "500m" + memory: "512Mi" + requests: + cpu: "100m" + memory: "128Mi" +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: rust-k8s-operator +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: rust-k8s-operator +rules: +- apiGroups: ["example.com"] + resources: ["myresources"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +- apiGroups: ["example.com"] + resources: ["myresources/status"] + verbs: ["get", "update", "patch"] +- apiGroups: [""] + resources: ["events"] + verbs: ["create", "patch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: rust-k8s-operator +subjects: +- kind: ServiceAccount + name: rust-k8s-operator + namespace: default +roleRef: + kind: ClusterRole + name: rust-k8s-operator + apiGroup: rbac.authorization.k8s.io diff --git a/manifests/example-cr.yaml b/manifests/example-cr.yaml new file mode 100644 index 0000000..aa4bb78 --- /dev/null +++ b/manifests/example-cr.yaml @@ -0,0 +1,7 @@ +apiVersion: example.com/v1 +kind: MyResource +metadata: + name: example-resource +spec: + message: "Hello from Rust K8s Operator!" + replicaCount: 3 diff --git a/src/bin/generate_crd.rs b/src/bin/generate_crd.rs new file mode 100644 index 0000000..ae381a7 --- /dev/null +++ b/src/bin/generate_crd.rs @@ -0,0 +1,25 @@ +use kube::CustomResourceExt; +use k8s_opeartor_sonarr::crd::sonarr::Sonarr; +use std::fs; +use std::path::Path; + +fn main() -> Result<(), Box> { + // Generate CRD definition + let crd = Sonarr::crd(); + + // Convert to YAML + let yaml = serde_yaml::to_string(&crd)?; + + // Create manifests directory if it doesn't exist + let path = Path::new("manifests"); + if !path.exists() { + fs::create_dir(path)?; + } + + // Write to file + fs::write("manifests/crd_2.yaml", yaml)?; + + println!("Successfully generated CRD manifest at manifests/crd.yaml"); + + Ok(()) +} \ No newline at end of file diff --git a/src/controller/mod.rs b/src/controller/mod.rs new file mode 100644 index 0000000..faaef06 --- /dev/null +++ b/src/controller/mod.rs @@ -0,0 +1 @@ +pub mod reconciler; diff --git a/src/controller/reconciler.rs b/src/controller/reconciler.rs new file mode 100644 index 0000000..d70f893 --- /dev/null +++ b/src/controller/reconciler.rs @@ -0,0 +1,79 @@ +use crate::crd::sonarr::Sonarr; +use crate::error::Error; +use chrono::Utc; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Time; +use kube::{ + api::{Api, Patch, PatchParams}, + client::Client, + runtime::controller::Action, + ResourceExt, +}; +use serde_json::json; +use std::{sync::Arc, time::Duration}; +use tracing::{error, info, warn}; + +/// State shared by the controller +pub struct SonarrState { + /// Kubernetes client + pub client: Client, +} + +impl SonarrState { + pub fn new(client: Client) -> Self { + SonarrState { client } + } +} + +/// Main reconciliation loop for the controller +pub async fn reconcile( + resource: Arc, + context: Arc, +) -> Result { + let client = context.client.clone(); + let name = resource.name_any(); + let namespace = resource.namespace().unwrap_or_else(|| "default".into()); + + info!("Reconciling Sonarr {} in {}", name, namespace); + + // Get API for Sonnar in the appropriate namespace + let api: Api = Api::namespaced(client, &namespace); + + // Initialize or update the status + let current_time = Utc::now(); + let status_patch = json!({ + "status": { + "observedGeneration": resource.metadata.generation, + "createdAt": current_time, + "conditions": [{ + "type": "Initialized", + "status": "True", + "lastTransitionTime": Time(current_time), + "reason": "ResourceCreated", + "message": "Resource has been initialized" + }], + "ready": true + } + }); + + // Apply the status update + let patch_params = PatchParams::apply("k8s-operator-sonarr").force(); + let _patched = api + .patch_status(&name, &patch_params, &Patch::Merge(&status_patch)) + .await + .map_err(|err| { + error!("Failed to patch status: {}", err); + Error::KubeError(err) + })?; + + info!("Successfully reconciled Sonnar {} in {}", name, namespace); + + // Requeue after 10 minutes by default + Ok(Action::requeue(Duration::from_secs(600))) +} + +/// Error policy for the controller +pub fn error_policy(resource: Arc, error: &Error, _ctx: Arc) -> Action { + warn!("Reconciliation error: {:?}", error); + // Retry after 5 minutes + Action::requeue(Duration::from_secs(300)) +} \ No newline at end of file diff --git a/src/crd/mod.rs b/src/crd/mod.rs new file mode 100644 index 0000000..76cf03c --- /dev/null +++ b/src/crd/mod.rs @@ -0,0 +1 @@ +pub mod sonarr; diff --git a/src/crd/sonarr.rs b/src/crd/sonarr.rs new file mode 100644 index 0000000..db6df97 --- /dev/null +++ b/src/crd/sonarr.rs @@ -0,0 +1,41 @@ +use chrono::{DateTime, Utc}; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Time; +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// Define the Custom Resource Definition (CRD) for our operator +#[derive(CustomResource, Serialize, Deserialize, Default, Debug, Clone, JsonSchema)] +#[kube( + group = "devopsarr.io", + version = "v1", + kind = "Sonarr", + plural = "sonarrs", + shortname = "son", + namespaced +)] +#[kube(status = "SonarrStatus")] +pub struct SonarrSpec { + pub message: String, + pub replica_count: Option, +} + +/// Status object for our CRD +#[derive(Serialize, Deserialize, Default, Debug, Clone, JsonSchema)] +pub struct SonarrStatus { + // pub created_at: Option>, + pub conditions: Vec, + pub observed_generation: Option, + pub ready: Option, +} + +/// Condition type for tracking the status of the resource +#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema)] +pub struct SonarrCondition { + #[serde(rename = "type")] + pub condition_type: String, + pub status: String, + //pub last_transition_time: Time, + pub reason: String, + pub message: String, +} diff --git a/src/error/mod.rs b/src/error/mod.rs new file mode 100644 index 0000000..e434704 --- /dev/null +++ b/src/error/mod.rs @@ -0,0 +1,14 @@ +use kube::Error as KubeError; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum Error { + #[error("Kubernetes API error: {0}")] + KubeError(#[from] KubeError), + + #[error("Serde JSON error: {0}")] + SerdeJsonError(#[from] serde_json::Error), + + #[error("Invalid spec: {0}")] + InvalidSpec(String), +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..5838e1c --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,6 @@ +// File: src/lib.rs + +// Re-export the crd module for use in the generate_crd binary +pub mod crd; +pub mod controller; +pub mod error; \ No newline at end of file diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..62349bf --- /dev/null +++ b/src/main.rs @@ -0,0 +1,68 @@ +use anyhow::Result; +use clap::Parser; +use futures::StreamExt; +use kube::{ + runtime::{controller::Controller, watcher::Config}, + Client, +}; +use std::sync::Arc; +use tokio::signal; +use tracing::info; + +mod crd; +mod controller; +mod error; + +use crd::sonarr::Sonarr; +use controller::reconciler::{reconcile, error_policy, SonarrState}; + +#[derive(Parser, Debug)] +#[clap(author, version, about, long_about = None)] +struct Opts { + #[arg(long)] + namespace: Option, +} + + +#[tokio::main] +async fn main() -> Result<()> { + // Initialize logging + tracing_subscriber::fmt::init(); + info!("Starting rust-k8s-operator"); + + // Parse command-line arguments + let opts = Opts::parse(); + + // Create Kubernetes client + let client = Client::try_default().await?; + info!("Successfully connected to Kubernetes cluster"); + + // Shared state for the controller + let state = Arc::new(SonarrState::new(client.clone())); + + // Set up controller for our custom resource based on namespace option + let sonarr_api = if let Some(namespace) = opts.namespace { + info!("Watching Sonarr resources in namespace: {}", namespace); + kube::Api::::namespaced(client.clone(), &namespace) + } else { + info!("Watching Sonarr resources across all namespaces"); + kube::Api::::all(client.clone()) + }; + + // Run the controller + Controller::new(sonarr_api, Config::default()) + .run(reconcile, error_policy, state) + .for_each(|res| async move { + match res { + Ok(o) => info!("Reconciled: {:?}", o), + Err(e) => tracing::error!("Reconcile error: {:?}", e), + } + }) + .await; + + // Wait for SIGTERM + signal::ctrl_c().await?; + info!("Shutting down rust-k8s-operator"); + + Ok(()) +} From 22fa591bcb456df468324fd6bbf93b22545a7ac1 Mon Sep 17 00:00:00 2001 From: Danilo Date: Fri, 18 Apr 2025 16:52:06 +0200 Subject: [PATCH 03/22] chore: working on it --- .dockerignore | 8 ++ .github/workflows/build_docker.yml | 103 ++++++++++++++++++ .gitignore | 1 + Cargo.toml | 2 +- Dockerfile | 8 +- manifests/crd.yaml | 77 ------------- manifests/{crd_2.yaml => crds/sonarr.yaml} | 2 +- manifests/deployment.yaml | 31 +++--- ...xample-cr.yaml => example-sonarr-crd.yaml} | 6 +- src/bin/generate_crd.rs | 10 +- src/crd/sonarr.rs | 5 +- src/main.rs | 25 +++-- 12 files changed, 162 insertions(+), 116 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/build_docker.yml delete mode 100644 manifests/crd.yaml rename manifests/{crd_2.yaml => crds/sonarr.yaml} (98%) rename manifests/{example-cr.yaml => example-sonarr-crd.yaml} (52%) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..58c5905 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +/.github +/manifests +/target +.vscode +.cursorrules +.idea +Cargo.lock +Dockerfile \ No newline at end of file diff --git a/.github/workflows/build_docker.yml b/.github/workflows/build_docker.yml new file mode 100644 index 0000000..f835b20 --- /dev/null +++ b/.github/workflows/build_docker.yml @@ -0,0 +1,103 @@ +name: Docker + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +on: + push: + branches: [ "main" ] + # Publish semver tags as releases. + tags: [ 'v*.*.*' ] + pull_request: + branches: [ "main"] + +env: + # Use docker.io for Docker Hub if empty + REGISTRY: ghcr.io + # github.repository as / + IMAGE_NAME: ${{ github.repository }} + + +jobs: + build: + + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + # This is used to complete the identity challenge + # with sigstore/fulcio when running outside of PRs. + id-token: write + + steps: + - name: DevOpsArrBOT token + id: DevOpsArrBOT + uses: getsentry/action-github-app-token@v3 + with: + app_id: '305652' + private_key: ${{ secrets.DEVOPSARRBOT_PRIVATE_KEY }} + + - name: Checkout repository + uses: actions/checkout@v4 + + # Install the cosign tool except on PR + # https://github.com/sigstore/cosign-installer + - name: Install cosign + if: github.event_name != 'pull_request' + uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 #v3.5.0 + with: + cosign-release: 'v2.2.4' + + # Set up BuildKit Docker container builder to be able to build + # multi-platform images and export cache + # https://github.com/docker/setup-buildx-action + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 + + # Login against a Docker registry except on PR + # https://github.com/docker/login-action + - name: Log into registry ${{ env.REGISTRY }} + if: github.event_name != 'pull_request' + uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ steps.DevOpsArrBOT.outputs.token }} + + # Extract metadata (tags, labels) for Docker + # https://github.com/docker/metadata-action + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + # Build and push Docker image with Buildx (don't push on PR) + # https://github.com/docker/build-push-action + - name: Build and push Docker image + id: build-and-push + uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0 + with: + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + # Sign the resulting Docker image digest except on PRs. + # This will only write to the public Rekor transparency log when the Docker + # repository is public to avoid leaking data. If you would like to publish + # transparency data even for private images, pass --force to cosign below. + # https://github.com/sigstore/cosign + - name: Sign the published Docker image + if: ${{ github.event_name != 'pull_request' }} + env: + # https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable + TAGS: ${{ steps.meta.outputs.tags }} + DIGEST: ${{ steps.build-and-push.outputs.digest }} + # This step uses the identity token to provision an ephemeral certificate + # against the sigstore community Fulcio instance. + run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST} diff --git a/.gitignore b/.gitignore index 0eb69bb..2bb9d54 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ Cargo.lock /target +.cursorrules \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index 261dd54..8972ae6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,7 @@ clap = { version = "4.3.19", features = ["derive"] } schemars = "0.8.12" [[bin]] -name = "k8s-opeartor-sonarr" +name = "k8s-operator-sonarr" path = "src/main.rs" [[bin]] diff --git a/Dockerfile b/Dockerfile index 2d43fce..370b19b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,13 @@ -FROM rust:1.71 as builder +FROM rust:1.85 as builder WORKDIR /app COPY . . RUN cargo build --release -FROM debian:bullseye-slim +FROM debian:bookworm-slim RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* -COPY --from=builder /app/target/release/rust-k8s-operator /usr/local/bin/ +COPY --from=builder /app/target/release/k8s-operator-sonarr /usr/local/bin/ -ENTRYPOINT ["k8s-opeartor-sonarr"] +ENTRYPOINT ["k8s-operator-sonarr"] diff --git a/manifests/crd.yaml b/manifests/crd.yaml deleted file mode 100644 index 18fa695..0000000 --- a/manifests/crd.yaml +++ /dev/null @@ -1,77 +0,0 @@ -apiVersion: apiextensions.k8s.io/v1 -kind: CustomResourceDefinition -metadata: - name: myresources.example.com -spec: - group: example.com - names: - kind: MyResource - plural: myresources - singular: myresource - shortNames: - - mr - scope: Namespaced - versions: - - name: v1 - served: true - storage: true - schema: - openAPIV3Schema: - type: object - required: - - spec - properties: - spec: - type: object - required: - - message - properties: - message: - type: string - description: A message to display - replicaCount: - type: integer - description: Number of replicas - minimum: 0 - status: - type: object - properties: - createdAt: - type: string - format: date-time - conditions: - type: array - items: - type: object - required: - - type - - status - - lastTransitionTime - - reason - - message - properties: - type: - type: string - status: - type: string - lastTransitionTime: - type: string - format: date-time - reason: - type: string - message: - type: string - observedGeneration: - type: integer - format: int64 - ready: - type: boolean - subresources: - status: {} - additionalPrinterColumns: - - name: Ready - type: boolean - jsonPath: .status.ready - - name: Age - type: date - jsonPath: .metadata.creationTimestamp diff --git a/manifests/crd_2.yaml b/manifests/crds/sonarr.yaml similarity index 98% rename from manifests/crd_2.yaml rename to manifests/crds/sonarr.yaml index 44cc0b5..8f63140 100644 --- a/manifests/crd_2.yaml +++ b/manifests/crds/sonarr.yaml @@ -24,7 +24,7 @@ spec: properties: message: type: string - replica_count: + replicaCount: format: int32 nullable: true type: integer diff --git a/manifests/deployment.yaml b/manifests/deployment.yaml index 8656776..f3f17db 100644 --- a/manifests/deployment.yaml +++ b/manifests/deployment.yaml @@ -1,23 +1,24 @@ apiVersion: apps/v1 kind: Deployment metadata: - name: rust-k8s-operator + name: sonarr-k8s-operator labels: - app: rust-k8s-operator + app: sonarr-k8s-operator spec: replicas: 1 selector: matchLabels: - app: rust-k8s-operator + app: sonarr-k8s-operator template: metadata: labels: - app: rust-k8s-operator + app: sonarr-k8s-operator spec: - serviceAccountName: rust-k8s-operator + serviceAccountName: sonarr-k8s-operator containers: - name: operator - image: your-registry/rust-k8s-operator:latest + image: k8s-operator-sonarr:latest + imagePullPolicy: Never resources: limits: cpu: "500m" @@ -29,18 +30,18 @@ spec: apiVersion: v1 kind: ServiceAccount metadata: - name: rust-k8s-operator + name: sonarr-k8s-operator --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: - name: rust-k8s-operator + name: sonarr-k8s-operator rules: -- apiGroups: ["example.com"] - resources: ["myresources"] +- apiGroups: ["devopsarr.io"] + resources: ["sonarrs"] verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] -- apiGroups: ["example.com"] - resources: ["myresources/status"] +- apiGroups: ["devopsarr.io"] + resources: ["sonarrs/status"] verbs: ["get", "update", "patch"] - apiGroups: [""] resources: ["events"] @@ -49,12 +50,12 @@ rules: apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: - name: rust-k8s-operator + name: sonarr-k8s-operator subjects: - kind: ServiceAccount - name: rust-k8s-operator + name: sonarr-k8s-operator namespace: default roleRef: kind: ClusterRole - name: rust-k8s-operator + name: sonarr-k8s-operator apiGroup: rbac.authorization.k8s.io diff --git a/manifests/example-cr.yaml b/manifests/example-sonarr-crd.yaml similarity index 52% rename from manifests/example-cr.yaml rename to manifests/example-sonarr-crd.yaml index aa4bb78..72272c0 100644 --- a/manifests/example-cr.yaml +++ b/manifests/example-sonarr-crd.yaml @@ -1,7 +1,7 @@ -apiVersion: example.com/v1 -kind: MyResource +apiVersion: devopsarr.io/v1 +kind: Sonarr metadata: - name: example-resource + name: sonarr spec: message: "Hello from Rust K8s Operator!" replicaCount: 3 diff --git a/src/bin/generate_crd.rs b/src/bin/generate_crd.rs index ae381a7..9219aac 100644 --- a/src/bin/generate_crd.rs +++ b/src/bin/generate_crd.rs @@ -3,7 +3,11 @@ use k8s_opeartor_sonarr::crd::sonarr::Sonarr; use std::fs; use std::path::Path; +use tracing::{info,debug}; + + fn main() -> Result<(), Box> { + tracing_subscriber::fmt::init(); // Generate CRD definition let crd = Sonarr::crd(); @@ -11,15 +15,15 @@ fn main() -> Result<(), Box> { let yaml = serde_yaml::to_string(&crd)?; // Create manifests directory if it doesn't exist - let path = Path::new("manifests"); + let path = Path::new("manifests/crds"); if !path.exists() { fs::create_dir(path)?; } // Write to file - fs::write("manifests/crd_2.yaml", yaml)?; + fs::write("manifests/crds/sonarr.yaml", yaml)?; - println!("Successfully generated CRD manifest at manifests/crd.yaml"); + debug!("Successfully generated CRD manifest at manifests/crds/sonarr.yaml"); Ok(()) } \ No newline at end of file diff --git a/src/crd/sonarr.rs b/src/crd/sonarr.rs index db6df97..cd9eb26 100644 --- a/src/crd/sonarr.rs +++ b/src/crd/sonarr.rs @@ -1,5 +1,3 @@ -use chrono::{DateTime, Utc}; -use k8s_openapi::apimachinery::pkg::apis::meta::v1::Time; use kube::CustomResource; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -15,9 +13,10 @@ use serde::{Deserialize, Serialize}; namespaced )] #[kube(status = "SonarrStatus")] +#[serde(rename_all = "camelCase")] pub struct SonarrSpec { pub message: String, - pub replica_count: Option, + pub replica_count: Option, //TODO: found a way to write compliant with code style } /// Status object for our CRD diff --git a/src/main.rs b/src/main.rs index 62349bf..e82121a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,14 +7,15 @@ use kube::{ }; use std::sync::Arc; use tokio::signal; -use tracing::info; +use tracing::{error, info, Level}; +use tracing_subscriber::{fmt, EnvFilter}; -mod crd; mod controller; +mod crd; mod error; +use controller::reconciler::{error_policy, reconcile, SonarrState}; use crd::sonarr::Sonarr; -use controller::reconciler::{reconcile, error_policy, SonarrState}; #[derive(Parser, Debug)] #[clap(author, version, about, long_about = None)] @@ -23,20 +24,22 @@ struct Opts { namespace: Option, } - #[tokio::main] async fn main() -> Result<()> { // Initialize logging - tracing_subscriber::fmt::init(); + fmt() + .with_env_filter(EnvFilter::from_default_env().add_directive(Level::INFO.into())) + .with_writer(std::io::stdout) // Forza l'output su stdout + .init(); info!("Starting rust-k8s-operator"); // Parse command-line arguments let opts = Opts::parse(); - + // Create Kubernetes client let client = Client::try_default().await?; info!("Successfully connected to Kubernetes cluster"); - + // Shared state for the controller let state = Arc::new(SonarrState::new(client.clone())); @@ -54,8 +57,12 @@ async fn main() -> Result<()> { .run(reconcile, error_policy, state) .for_each(|res| async move { match res { - Ok(o) => info!("Reconciled: {:?}", o), - Err(e) => tracing::error!("Reconcile error: {:?}", e), + Ok(o) => { + info!("Reconciled: {:?}", o); + } + Err(e) => { + error!("Reconcile error: {:?}", e); + } } }) .await; From 36d832db18d66e842629c8054e41c00b939a62ce Mon Sep 17 00:00:00 2001 From: Danilo Date: Fri, 18 Apr 2025 16:54:19 +0200 Subject: [PATCH 04/22] chore: better logging --- src/bin/generate_crd.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/bin/generate_crd.rs b/src/bin/generate_crd.rs index 9219aac..332bdcf 100644 --- a/src/bin/generate_crd.rs +++ b/src/bin/generate_crd.rs @@ -3,11 +3,17 @@ use k8s_opeartor_sonarr::crd::sonarr::Sonarr; use std::fs; use std::path::Path; -use tracing::{info,debug}; +use tracing::{debug, Level}; +use tracing_subscriber::{fmt, EnvFilter}; fn main() -> Result<(), Box> { tracing_subscriber::fmt::init(); + fmt() + .with_env_filter(EnvFilter::from_default_env().add_directive(Level::INFO.into())) + .with_writer(std::io::stdout) // Forza l'output su stdout + .init(); + debug!("Build CRD..."); // Generate CRD definition let crd = Sonarr::crd(); From da3c78896555b4c51498c28a5b17210e8a6abd70 Mon Sep 17 00:00:00 2001 From: Fuochi Date: Tue, 10 Feb 2026 20:55:08 +0100 Subject: [PATCH 05/22] feat! opus try --- .github/workflows/build_docker.yml | 103 - .github/workflows/ci.yml | 262 + .gitignore | 69 +- Cargo.lock | 2953 ++++++ Cargo.toml | 88 +- Dockerfile | 32 +- Makefile | 268 + README.md | 293 +- deploy/crds/autotag.yaml | 182 + deploy/crds/customformat.yaml | 173 + deploy/crds/delayprofile.yaml | 165 + deploy/crds/downloadclient.yaml | 289 + deploy/crds/downloadclientconfig.yaml | 121 + deploy/crds/importlist.yaml | 262 + deploy/crds/indexer.yaml | 254 + deploy/crds/indexerconfig.yaml | 125 + deploy/crds/languageprofile.yaml | 245 + deploy/crds/mediamanagementconfig.yaml | 203 + deploy/crds/metadata.yaml | 173 + deploy/crds/namingconfig.yaml | 162 + deploy/crds/notification.yaml | 462 + deploy/crds/qualitydefinition.yaml | 163 + deploy/crds/qualityprofile.yaml | 203 + deploy/crds/rootfolder.yaml | 122 + deploy/crds/series.yaml | 238 + deploy/crds/sonarr.yaml | 640 ++ deploy/crds/tag.yaml | 113 + deploy/deployment.yaml | 40 + deploy/namespace.yaml | 7 + deploy/rbac.yaml | 59 + docs/TESTING.md | 282 + docs/api/crd-reference.md | 8806 +++++++++++++++++ manifests/crds/sonarr.yaml | 74 - manifests/deployment.yaml | 61 - manifests/example-sonarr-crd.yaml | 7 - src/api/mod.rs | 73 + src/bin/crdgen.rs | 126 + src/bin/generate_crd.rs | 35 - src/controller/mod.rs | 1 - src/controller/reconciler.rs | 79 - src/controllers/auto_tag.rs | 154 + src/controllers/custom_format.rs | 155 + src/controllers/delay_profile.rs | 108 + src/controllers/download_client.rs | 123 + src/controllers/download_client_config.rs | 213 + src/controllers/import_list.rs | 151 + src/controllers/indexer.rs | 155 + src/controllers/indexer_config.rs | 179 + src/controllers/language_profile.rs | 206 + src/controllers/media_management_config.rs | 341 + src/controllers/metadata.rs | 117 + src/controllers/mod.rs | 27 + src/controllers/naming_config.rs | 235 + src/controllers/notification.rs | 138 + src/controllers/quality_definition.rs | 130 + src/controllers/quality_profile.rs | 132 + src/controllers/root_folder.rs | 113 + src/controllers/series.rs | 162 + src/controllers/sonarr.rs | 1129 +++ src/controllers/tag.rs | 187 + src/controllers/traits.rs | 200 + src/controllers/utils.rs | 54 + src/crd/mod.rs | 1 - src/crd/sonarr.rs | 40 - src/crds/auto_tag.rs | 124 + src/crds/custom_format.rs | 121 + src/crds/delay_profile.rs | 98 + src/crds/download_client.rs | 203 + src/crds/download_client_config.rs | 62 + src/crds/import_list.rs | 204 + src/crds/indexer.rs | 166 + src/crds/indexer_config.rs | 62 + src/crds/language_profile.rs | 133 + src/crds/media_management_config.rs | 160 + src/crds/metadata.rs | 108 + src/crds/mod.rs | 114 + src/crds/naming_config.rs | 95 + src/crds/notification.rs | 321 + src/crds/quality_definition.rs | 141 + src/crds/quality_profile.rs | 126 + src/crds/root_folder.rs | 55 + src/crds/series.rs | 200 + src/crds/sonarr.rs | 636 ++ src/crds/tag.rs | 47 + src/error.rs | 52 + src/error/mod.rs | 14 - src/lib.rs | 27 +- src/main.rs | 136 +- tests/e2e/common.rs | 489 + tests/e2e/fixtures/01-sonarr-instance.yaml | 7 + tests/e2e/fixtures/02-tags.yaml | 30 + tests/e2e/fixtures/03-root-folders.yaml | 20 + tests/e2e/fixtures/cleanup.sh | 27 + tests/e2e/fixtures/setup.sh | 102 + tests/e2e/fixtures/sonarr-deployment.yaml | 114 + tests/e2e/fixtures/sonarr-instance.yaml | 28 + tests/e2e/main.rs | 23 + tests/e2e/scenarios/config_resources.rs | 260 + .../scenarios/cross_resource_dependencies.rs | 502 + .../e2e/scenarios/custom_format_lifecycle.rs | 93 + tests/e2e/scenarios/error_recovery.rs | 318 + tests/e2e/scenarios/mod.rs | 11 + .../scenarios/quality_profile_lifecycle.rs | 160 + tests/e2e/scenarios/root_folder_lifecycle.rs | 92 + tests/e2e/scenarios/tag_lifecycle.rs | 222 + tests/e2e/sonarr_client.rs | 480 + tests/integration/auto_tag_crd.rs | 294 + tests/integration/common.rs | 207 + tests/integration/custom_format_crd.rs | 230 + tests/integration/delay_profile_crd.rs | 225 + .../integration/download_client_config_crd.rs | 190 + tests/integration/import_list_crd.rs | 236 + tests/integration/indexer_config_crd.rs | 189 + tests/integration/language_profile_crd.rs | 223 + tests/integration/main.rs | 25 + .../media_management_config_crd.rs | 269 + tests/integration/metadata_crd.rs | 213 + tests/integration/naming_config_crd.rs | 199 + tests/integration/quality_definition_crd.rs | 276 + tests/integration/root_folder_crd.rs | 247 + tests/integration/sonarr_crd.rs | 389 + tests/integration/tag_crd.rs | 239 + 122 files changed, 31926 insertions(+), 571 deletions(-) delete mode 100644 .github/workflows/build_docker.yml create mode 100644 .github/workflows/ci.yml create mode 100644 Cargo.lock create mode 100644 Makefile create mode 100644 deploy/crds/autotag.yaml create mode 100644 deploy/crds/customformat.yaml create mode 100644 deploy/crds/delayprofile.yaml create mode 100644 deploy/crds/downloadclient.yaml create mode 100644 deploy/crds/downloadclientconfig.yaml create mode 100644 deploy/crds/importlist.yaml create mode 100644 deploy/crds/indexer.yaml create mode 100644 deploy/crds/indexerconfig.yaml create mode 100644 deploy/crds/languageprofile.yaml create mode 100644 deploy/crds/mediamanagementconfig.yaml create mode 100644 deploy/crds/metadata.yaml create mode 100644 deploy/crds/namingconfig.yaml create mode 100644 deploy/crds/notification.yaml create mode 100644 deploy/crds/qualitydefinition.yaml create mode 100644 deploy/crds/qualityprofile.yaml create mode 100644 deploy/crds/rootfolder.yaml create mode 100644 deploy/crds/series.yaml create mode 100644 deploy/crds/sonarr.yaml create mode 100644 deploy/crds/tag.yaml create mode 100644 deploy/deployment.yaml create mode 100644 deploy/namespace.yaml create mode 100644 deploy/rbac.yaml create mode 100644 docs/TESTING.md create mode 100644 docs/api/crd-reference.md delete mode 100644 manifests/crds/sonarr.yaml delete mode 100644 manifests/deployment.yaml delete mode 100644 manifests/example-sonarr-crd.yaml create mode 100644 src/api/mod.rs create mode 100644 src/bin/crdgen.rs delete mode 100644 src/bin/generate_crd.rs delete mode 100644 src/controller/mod.rs delete mode 100644 src/controller/reconciler.rs create mode 100644 src/controllers/auto_tag.rs create mode 100644 src/controllers/custom_format.rs create mode 100644 src/controllers/delay_profile.rs create mode 100644 src/controllers/download_client.rs create mode 100644 src/controllers/download_client_config.rs create mode 100644 src/controllers/import_list.rs create mode 100644 src/controllers/indexer.rs create mode 100644 src/controllers/indexer_config.rs create mode 100644 src/controllers/language_profile.rs create mode 100644 src/controllers/media_management_config.rs create mode 100644 src/controllers/metadata.rs create mode 100644 src/controllers/mod.rs create mode 100644 src/controllers/naming_config.rs create mode 100644 src/controllers/notification.rs create mode 100644 src/controllers/quality_definition.rs create mode 100644 src/controllers/quality_profile.rs create mode 100644 src/controllers/root_folder.rs create mode 100644 src/controllers/series.rs create mode 100644 src/controllers/sonarr.rs create mode 100644 src/controllers/tag.rs create mode 100644 src/controllers/traits.rs create mode 100644 src/controllers/utils.rs delete mode 100644 src/crd/mod.rs delete mode 100644 src/crd/sonarr.rs create mode 100644 src/crds/auto_tag.rs create mode 100644 src/crds/custom_format.rs create mode 100644 src/crds/delay_profile.rs create mode 100644 src/crds/download_client.rs create mode 100644 src/crds/download_client_config.rs create mode 100644 src/crds/import_list.rs create mode 100644 src/crds/indexer.rs create mode 100644 src/crds/indexer_config.rs create mode 100644 src/crds/language_profile.rs create mode 100644 src/crds/media_management_config.rs create mode 100644 src/crds/metadata.rs create mode 100644 src/crds/mod.rs create mode 100644 src/crds/naming_config.rs create mode 100644 src/crds/notification.rs create mode 100644 src/crds/quality_definition.rs create mode 100644 src/crds/quality_profile.rs create mode 100644 src/crds/root_folder.rs create mode 100644 src/crds/series.rs create mode 100644 src/crds/sonarr.rs create mode 100644 src/crds/tag.rs create mode 100644 src/error.rs delete mode 100644 src/error/mod.rs create mode 100644 tests/e2e/common.rs create mode 100644 tests/e2e/fixtures/01-sonarr-instance.yaml create mode 100644 tests/e2e/fixtures/02-tags.yaml create mode 100644 tests/e2e/fixtures/03-root-folders.yaml create mode 100644 tests/e2e/fixtures/cleanup.sh create mode 100644 tests/e2e/fixtures/setup.sh create mode 100644 tests/e2e/fixtures/sonarr-deployment.yaml create mode 100644 tests/e2e/fixtures/sonarr-instance.yaml create mode 100644 tests/e2e/main.rs create mode 100644 tests/e2e/scenarios/config_resources.rs create mode 100644 tests/e2e/scenarios/cross_resource_dependencies.rs create mode 100644 tests/e2e/scenarios/custom_format_lifecycle.rs create mode 100644 tests/e2e/scenarios/error_recovery.rs create mode 100644 tests/e2e/scenarios/mod.rs create mode 100644 tests/e2e/scenarios/quality_profile_lifecycle.rs create mode 100644 tests/e2e/scenarios/root_folder_lifecycle.rs create mode 100644 tests/e2e/scenarios/tag_lifecycle.rs create mode 100644 tests/e2e/sonarr_client.rs create mode 100644 tests/integration/auto_tag_crd.rs create mode 100644 tests/integration/common.rs create mode 100644 tests/integration/custom_format_crd.rs create mode 100644 tests/integration/delay_profile_crd.rs create mode 100644 tests/integration/download_client_config_crd.rs create mode 100644 tests/integration/import_list_crd.rs create mode 100644 tests/integration/indexer_config_crd.rs create mode 100644 tests/integration/language_profile_crd.rs create mode 100644 tests/integration/main.rs create mode 100644 tests/integration/media_management_config_crd.rs create mode 100644 tests/integration/metadata_crd.rs create mode 100644 tests/integration/naming_config_crd.rs create mode 100644 tests/integration/quality_definition_crd.rs create mode 100644 tests/integration/root_folder_crd.rs create mode 100644 tests/integration/sonarr_crd.rs create mode 100644 tests/integration/tag_crd.rs diff --git a/.github/workflows/build_docker.yml b/.github/workflows/build_docker.yml deleted file mode 100644 index f835b20..0000000 --- a/.github/workflows/build_docker.yml +++ /dev/null @@ -1,103 +0,0 @@ -name: Docker - -# This workflow uses actions that are not certified by GitHub. -# They are provided by a third-party and are governed by -# separate terms of service, privacy policy, and support -# documentation. - -on: - push: - branches: [ "main" ] - # Publish semver tags as releases. - tags: [ 'v*.*.*' ] - pull_request: - branches: [ "main"] - -env: - # Use docker.io for Docker Hub if empty - REGISTRY: ghcr.io - # github.repository as / - IMAGE_NAME: ${{ github.repository }} - - -jobs: - build: - - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - # This is used to complete the identity challenge - # with sigstore/fulcio when running outside of PRs. - id-token: write - - steps: - - name: DevOpsArrBOT token - id: DevOpsArrBOT - uses: getsentry/action-github-app-token@v3 - with: - app_id: '305652' - private_key: ${{ secrets.DEVOPSARRBOT_PRIVATE_KEY }} - - - name: Checkout repository - uses: actions/checkout@v4 - - # Install the cosign tool except on PR - # https://github.com/sigstore/cosign-installer - - name: Install cosign - if: github.event_name != 'pull_request' - uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 #v3.5.0 - with: - cosign-release: 'v2.2.4' - - # Set up BuildKit Docker container builder to be able to build - # multi-platform images and export cache - # https://github.com/docker/setup-buildx-action - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 - - # Login against a Docker registry except on PR - # https://github.com/docker/login-action - - name: Log into registry ${{ env.REGISTRY }} - if: github.event_name != 'pull_request' - uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ steps.DevOpsArrBOT.outputs.token }} - - # Extract metadata (tags, labels) for Docker - # https://github.com/docker/metadata-action - - name: Extract Docker metadata - id: meta - uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - - # Build and push Docker image with Buildx (don't push on PR) - # https://github.com/docker/build-push-action - - name: Build and push Docker image - id: build-and-push - uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0 - with: - context: . - push: ${{ github.event_name != 'pull_request' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - - # Sign the resulting Docker image digest except on PRs. - # This will only write to the public Rekor transparency log when the Docker - # repository is public to avoid leaking data. If you would like to publish - # transparency data even for private images, pass --force to cosign below. - # https://github.com/sigstore/cosign - - name: Sign the published Docker image - if: ${{ github.event_name != 'pull_request' }} - env: - # https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable - TAGS: ${{ steps.meta.outputs.tags }} - DIGEST: ${{ steps.build-and-push.outputs.digest }} - # This step uses the identity token to provision an ephemeral certificate - # against the sigstore community Fulcio instance. - run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8ba845a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,262 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +jobs: + # ============================================ + # Lint and Format Check + # ============================================ + lint: + name: Lint & Format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + - name: Check formatting + run: cargo fmt -- --check + - name: Clippy + run: cargo clippy -- -D warnings + + # ============================================ + # Unit Tests + # ============================================ + test: + name: Unit Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Run unit tests + run: cargo test --lib + + # ============================================ + # Build + # ============================================ + build: + name: Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Build + run: cargo build --release + - name: Generate CRDs + run: cargo run --bin crdgen -- --split deploy/crds + - name: Upload CRDs artifact + uses: actions/upload-artifact@v4 + with: + name: crds + path: deploy/crds/ + + # ============================================ + # Generate CRD Documentation + # ============================================ + docs: + name: Generate Docs + runs-on: ubuntu-latest + needs: [build] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - uses: actions/setup-go@v5 + with: + go-version: '1.22' + + - name: Install crdoc + run: go install fybrik.io/crdoc@latest + + - name: Generate CRDs + run: cargo run --bin crdgen -- --split deploy/crds + + - name: Generate CRD documentation + run: | + mkdir -p docs/api + crdoc --resources deploy/crds --output docs/api/crd-reference.md + + - name: Upload documentation artifact + uses: actions/upload-artifact@v4 + with: + name: docs + path: docs/api/ + + # ============================================ + # Integration Tests (CRD validation) + # ============================================ + integration-test: + name: Integration Tests (K8s ${{ matrix.k8s }}) + runs-on: ubuntu-latest + needs: [lint, test, build] + strategy: + fail-fast: false + matrix: + k8s: [v1.28, v1.29, latest] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + + - name: Set up k3d cluster + uses: nolar/setup-k3d-k3s@v1 + with: + version: ${{ matrix.k8s }} + k3d-name: sonarr-operator-test + k3d-args: '--no-lb --no-rollback --k3s-arg --disable=traefik,servicelb,metrics-server@server:*' + + - name: Generate and install CRDs + run: | + cargo run --bin crdgen -- --split deploy/crds + kubectl apply -f deploy/crds/ + + - name: Wait for CRDs + run: | + kubectl wait --for=condition=established crd/sonarrs.devopsarr.io --timeout=60s + kubectl wait --for=condition=established crd/sonarrtags.devopsarr.io --timeout=60s + kubectl wait --for=condition=established crd/sonarrrootfolders.devopsarr.io --timeout=60s + kubectl wait --for=condition=established crd/sonarrindexers.devopsarr.io --timeout=60s + kubectl wait --for=condition=established crd/sonarrdownloadclients.devopsarr.io --timeout=60s + kubectl wait --for=condition=established crd/sonarrnotifications.devopsarr.io --timeout=60s + kubectl wait --for=condition=established crd/sonarrqualityprofiles.devopsarr.io --timeout=60s + kubectl wait --for=condition=established crd/sonarrseries.devopsarr.io --timeout=60s + + - name: Run integration tests + run: cargo test --test integration -- --ignored --test-threads=1 + + - name: Cleanup + if: always() + run: kubectl delete namespace sonarr-operator-test --ignore-not-found + + # ============================================ + # E2E Tests (Full reconciliation with Sonarr) + # ============================================ + e2e-test: + name: E2E Tests + runs-on: ubuntu-latest + needs: [integration-test] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + + - name: Set up k3d cluster + uses: nolar/setup-k3d-k3s@v1 + with: + version: latest + k3d-name: sonarr-operator-e2e + k3d-args: '--no-lb --no-rollback --k3s-arg --disable=traefik,servicelb,metrics-server@server:*' + + - name: Generate and install CRDs + run: | + cargo run --bin crdgen -- --split deploy/crds + kubectl apply -f deploy/crds/ + + - name: Deploy Sonarr + run: | + kubectl apply -f tests/e2e/fixtures/sonarr-deployment.yaml + echo "Waiting for Sonarr deployment..." + kubectl wait --for=condition=available deployment/sonarr -n default --timeout=300s + echo "Waiting for Sonarr pod..." + kubectl wait --for=condition=ready pod -l app=sonarr -n default --timeout=300s + echo "Giving Sonarr time to initialize..." + sleep 60 + + - name: Extract Sonarr API key + id: sonarr-key + run: | + SONARR_POD=$(kubectl get pod -l app=sonarr -n default -o jsonpath='{.items[0].metadata.name}') + for i in {1..20}; do + API_KEY=$(kubectl exec "${SONARR_POD}" -n default -- cat /config/config.xml 2>/dev/null | grep -oP '(?<=)[^<]+' || true) + if [ -n "${API_KEY}" ]; then + echo "API key extracted" + echo "api_key=${API_KEY}" >> $GITHUB_OUTPUT + break + fi + echo "Waiting for API key... (attempt $i/20)" + sleep 10 + done + if [ -z "${API_KEY}" ]; then + echo "Failed to extract API key" + kubectl logs "${SONARR_POD}" -n default --tail=100 + exit 1 + fi + + - name: Create Sonarr instance + run: | + kubectl create secret generic sonarr-api-key \ + --from-literal=api-key="${{ steps.sonarr-key.outputs.api_key }}" \ + -n default + kubectl apply -f tests/e2e/fixtures/sonarr-instance.yaml + + - name: Start operator in background + run: | + cargo build --release + RUST_LOG=info ./target/release/sonarr-operator & + echo $! > /tmp/operator.pid + sleep 10 + + - name: Setup port-forward + run: | + kubectl port-forward svc/sonarr 8989:8989 -n default & + echo $! > /tmp/port-forward.pid + sleep 5 + + - name: Run E2E tests + env: + SONARR_API_KEY: ${{ steps.sonarr-key.outputs.api_key }} + SONARR_URL: http://localhost:8989 + run: cargo test --test e2e -- --ignored --test-threads=1 --nocapture + + - name: Cleanup + if: always() + run: | + if [ -f /tmp/operator.pid ]; then + kill $(cat /tmp/operator.pid) 2>/dev/null || true + fi + if [ -f /tmp/port-forward.pid ]; then + kill $(cat /tmp/port-forward.pid) 2>/dev/null || true + fi + + - name: Collect logs on failure + if: failure() + run: | + echo "=== Operator logs ===" + kubectl logs -l app=sonarr-operator -n sonarr-operator-system --tail=100 2>/dev/null || echo "No operator logs" + echo "=== Sonarr logs ===" + kubectl logs -l app=sonarr -n default --tail=100 2>/dev/null || echo "No Sonarr logs" + echo "=== All resources ===" + kubectl get all -A + echo "=== CRDs ===" + kubectl get sonarrs,sonarrtags,sonarrrootfolders -A 2>/dev/null || true + + # ============================================ + # Docker Build + # ============================================ + docker: + name: Docker Build + runs-on: ubuntu-latest + needs: [lint, test, build] + steps: + - uses: actions/checkout@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Build Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: false + tags: sonarr-operator:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore index 2bb9d54..eac99f1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,27 +1,56 @@ -# Generated by Cargo -# will have compiled files and executables -debug/ -target/ +# Rust build artifacts +/target/ +**/*.rs.bk -# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries -# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html -Cargo.lock +# IDE and editor files +.idea/ +.vscode/ +*.swp +*.swo +*~ +.project +.classpath +.settings/ -# These are backup files generated by rustfmt -**/*.rs.bk +# OS files +.DS_Store +.DS_Store? +._* +Thumbs.db +ehthumbs.db +Desktop.ini + +# Environment and secrets +.env +.env.local +.env.*.local +*.pem +*.key +kubeconfig +kubeconfig.yaml + +# Local development +.cargo/ +*.log +*.tmp -# MSVC Windows builds of rustc generate these, which store debugging information -*.pdb +# Docker +.docker/ -# RustRover -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +# Kubernetes testing artifacts +*.kubeconfig +kind-config.yaml -# Added by cargo +# Coverage reports +*.profraw +*.profdata +coverage/ +lcov.info +tarpaulin-report.html -/target +# Documentation build +/doc/ -.cursorrules \ No newline at end of file +# Binary artifacts (if accidentally placed in root) +sonarr-operator +crdgen diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..70dd308 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2953 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", + "gloo-timers", + "tokio", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "cc" +version = "1.2.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", + "quote", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn", +] + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "educe" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "enum-ordinalize" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a1091a7bb1f8f2c4b28f1fe2cef4980ca2d410a3d727d67ecc3178c9b0800f0" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.13.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jiff" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67e8da4c49d6d9909fe03361f9b620f58898859f5c7aded68351e85e71ecf50" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c84ee7f197eca9a86c6fd6cb771e55eb991632f15f2bc3ca6ec838929e6e78" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f300e415e2134745ef75f04562dd0145405c2f7fd92065db029ac4b16b57fe90" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonpath-rust" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633a7320c4bb672863a3782e89b9094ad70285e097ff6832cddd0ec615beadfa" +dependencies = [ + "pest", + "pest_derive", + "regex", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "jsonptr" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5a3cc660ba5d72bce0b3bb295bf20847ccbb40fd423f3f05b61273672e561fe" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "k8s-openapi" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05a6d6f3611ad1d21732adbd7a2e921f598af6c92d71ae6e2620da4b67ee1f0d" +dependencies = [ + "base64", + "jiff", + "schemars 1.2.0", + "serde", + "serde_json", +] + +[[package]] +name = "kube" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dae7229247e4215781e5c5104a056e1e2163943e577f9084cf8bba7b5248f7a" +dependencies = [ + "k8s-openapi", + "kube-client", + "kube-core", + "kube-derive", + "kube-runtime", +] + +[[package]] +name = "kube-client" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010875e291a9c0a4e076f4f9c35b97d82fd2372cb3bc713252c3d08b7e73ce5b" +dependencies = [ + "base64", + "bytes", + "either", + "futures", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-timeout", + "hyper-util", + "jiff", + "jsonpath-rust", + "k8s-openapi", + "kube-core", + "pem", + "rustls", + "secrecy", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tracing", +] + +[[package]] +name = "kube-core" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac76281aa698dd34111e25b21f5f6561932a30feabab5357152be273f8a81bb" +dependencies = [ + "derive_more", + "form_urlencoded", + "http", + "jiff", + "json-patch", + "k8s-openapi", + "schemars 1.2.0", + "serde", + "serde-value", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "kube-derive" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "599c09721efcccc0e6a26e93df28c587da60ff5e099c657626fff2af0ae4cbb8" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn", +] + +[[package]] +name = "kube-runtime" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db43d26700f564baf850f681f3cb0f1195d2699bd379bfa70750ecec4dcb209" +dependencies = [ + "ahash", + "async-broadcast", + "async-stream", + "backon", + "educe", + "futures", + "hashbrown 0.16.1", + "hostname", + "json-patch", + "k8s-openapi", + "kube-client", + "parking_lot", + "pin-project", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe 0.1.6", + "openssl-sys", + "schannel", + "security-framework 2.11.1", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9eb05c21a464ea704b53158d358a31e6425db2f63a1a7312268b05fe2b75f7" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f9dbced329c441fa79d80472764b1a2c7e57123553b8519b36663a2fb234ed" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bb96d5051a78f44f43c8f712d8e810adb0ebf923fc9ed2655a7f66f63ba8ee5" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "602113b5b5e8621770cfd490cfd90b9f84ab29bd2b0e49ad83eb6d186cef2365" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "portable-atomic" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "mime_guess", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe 0.2.1", + "rustls-pki-types", + "schannel", + "security-framework 3.5.1", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54e910108742c57a770f492731f99be216a52fadd361b06c8fb59d74ccc267d2" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4908ad288c5035a8eb12cfdf0d49270def0a268ee162b75eeee0f85d155a7c45" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" +dependencies = [ + "base64", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.13.0", + "schemars 0.9.0", + "schemars 1.2.0", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c" +dependencies = [ + "darling 0.21.3", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.13.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "sonarr" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66a60e7bb8becc4b5ebd3761fdded2e7b9d756812a18d17a265844ca76d29a19" +dependencies = [ + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serde_with", + "url", +] + +[[package]] +name = "sonarr-operator" +version = "0.1.0" +dependencies = [ + "anyhow", + "assert-json-diff", + "base64", + "chrono", + "futures", + "k8s-openapi", + "kube", + "reqwest", + "rustls", + "schemars 1.2.0", + "serde", + "serde_json", + "serde_yaml", + "sonarr", + "thiserror 2.0.18", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.52.0", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9da98b7d9b7dad93488a84b8248efc35352b0b2657397d4167e7ad67e5d535e5" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78cc610bac2dcee56805c99642447d4c5dbde4d01f752ffea0199aee1f601dc4" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "slab", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "base64", + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "mime", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dafd85c832c1b68bbb4ec0c72c7f6f4fc5179627d2bc7c26b30e4c0cc11e76cc" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cb7e4e8436d9db52fbd6625dbf2f45243ab84994a72882ec8227b99e72b439a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02aae0f83f69aafc94776e879363e9771d7ecbffe2c7fbb6c14c5e00dfe88439" diff --git a/Cargo.toml b/Cargo.toml index 8972ae6..9c840d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,29 +1,77 @@ [package] -name = "k8s-opeartor-sonarr" +name = "sonarr-operator" version = "0.1.0" -edition = "2021" +edition = "2024" +authors = ["Sonarr Operator Contributors"] +description = "Kubernetes Operator for Sonarr written in Rust using kube-rs" +license = "MIT" +repository = "https://github.com/your-org/kubernetes-operator-sonarr" +keywords = ["kubernetes", "operator", "sonarr", "kube-rs"] + +[lib] +name = "sonarr_operator" +path = "src/lib.rs" [dependencies] -kube = { version = "0.84.0", features = ["runtime", "derive"] } -k8s-openapi = { version = "0.18.0", features = ["v1_26"] } -tokio = { version = "1.29.1", features = ["full"] } -futures = "0.3.28" -anyhow = "1.0.72" -thiserror = "1.0.44" -serde = { version = "1.0.174", features = ["derive"] } -serde_json = "1.0.103" -serde_yaml = "0.9.25" -tracing = "0.1.37" -tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } -chrono = { version = "0.4.26", features = ["serde"] } -async-trait = "0.1.71" -clap = { version = "4.3.19", features = ["derive"] } -schemars = "0.8.12" +# Kubernetes client and runtime +kube = { version = "3.0.0", features = ["runtime", "derive", "client", "rustls-tls"], default-features = false } +k8s-openapi = { version = "0.27.0", features = ["latest", "schemars"] } + +# Crypto provider for rustls (required at runtime) +rustls = { version = "0.23", default-features = false, features = ["ring"] } + +# Async runtime +tokio = { version = "1", features = ["full"] } +futures = "0.3" + +# Serialization +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yaml = "0.9" + +# JSON Schema generation for CRDs +schemars = "1" + +# Sonarr API client +sonarr = { version = "0.1" } + +# Error handling +thiserror = "2" +anyhow = "1" + +# Logging and tracing +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } + +# Time handling +chrono = { version = "0.4", features = ["serde"] } + +# Base64 encoding for secrets +base64 = "0.22" + +[dev-dependencies] +assert-json-diff = "2" +reqwest = { version = "0.12", features = ["json"] } +anyhow = "1" [[bin]] -name = "k8s-operator-sonarr" +name = "sonarr-operator" path = "src/main.rs" [[bin]] -name = "generate_crd" -path = "src/bin/generate_crd.rs" \ No newline at end of file +name = "crdgen" +path = "src/bin/crdgen.rs" + +[[test]] +name = "integration" +path = "tests/integration/main.rs" + +[[test]] +name = "e2e" +path = "tests/e2e/main.rs" + +[profile.release] +lto = true +codegen-units = 1 +opt-level = "z" +strip = true diff --git a/Dockerfile b/Dockerfile index 370b19b..3f4427a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,31 @@ -FROM rust:1.85 as builder +FROM rust:1.88-slim-bookworm AS builder WORKDIR /app -COPY . . -RUN cargo build --release -FROM debian:bookworm-slim +# Install dependencies +RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* -RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* +# Copy manifests +COPY Cargo.toml Cargo.lock ./ -COPY --from=builder /app/target/release/k8s-operator-sonarr /usr/local/bin/ +# Create dummy src to cache dependencies +RUN mkdir src && \ + echo "fn main() {}" > src/main.rs && \ + echo "" > src/lib.rs && \ + mkdir -p src/bin && echo "fn main() {}" > src/bin/crdgen.rs -ENTRYPOINT ["k8s-operator-sonarr"] +# Build dependencies +RUN cargo build --release && rm -rf src + +# Copy actual source code +COPY src ./src + +# Build the application +RUN touch src/main.rs src/lib.rs && cargo build --release --bin sonarr-operator + +# Runtime image - use distroless for smaller image and no apt needed +FROM gcr.io/distroless/cc-debian12:nonroot + +COPY --from=builder /app/target/release/sonarr-operator /usr/local/bin/sonarr-operator + +ENTRYPOINT ["/usr/local/bin/sonarr-operator"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..faa85bf --- /dev/null +++ b/Makefile @@ -0,0 +1,268 @@ +# Sonarr Kubernetes Operator Makefile +# +# Usage: +# make help - Show this help +# make check - Run cargo check +# make build - Build release binary +# make crds - Generate CRD manifests +# make install - Install CRDs to cluster +# make run - Run operator locally +# make docker - Build Docker image +# make test - Run tests +# make e2e - Run end-to-end tests + +# Configuration +BINARY_NAME := sonarr-operator +IMAGE_NAME := sonarr-operator +IMAGE_TAG := latest +NAMESPACE := sonarr-operator-system +CRD_DIR := deploy/crds + +# Kubernetes context (optional, uses current context if not set) +# KUBECONFIG := ~/.kube/config + +.PHONY: help +help: ## Show this help + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' + +##@ Development + +.PHONY: check +check: ## Run cargo check + cargo check + +.PHONY: fmt +fmt: ## Format code + cargo fmt + +.PHONY: fmt-check +fmt-check: ## Check code formatting + cargo fmt -- --check + +.PHONY: clippy +clippy: ## Run clippy linter + cargo clippy -- -D warnings + +.PHONY: lint +lint: fmt-check clippy ## Run all linters + +.PHONY: test +test: ## Run unit tests + cargo test + +.PHONY: test-verbose +test-verbose: ## Run tests with verbose output + cargo test -- --nocapture + +.PHONY: integration-test +integration-test: ## Run integration tests (requires cluster and CRDs installed) + cargo test --test integration -- --ignored --test-threads=1 + +.PHONY: integration-test-verbose +integration-test-verbose: ## Run integration tests with verbose output + cargo test --test integration -- --ignored --test-threads=1 --nocapture + +.PHONY: integration-setup +integration-setup: kind-create install ## Setup integration test environment + @echo "Integration test environment ready" + @echo "Run 'make integration-test' to execute tests" + +.PHONY: integration-cleanup +integration-cleanup: ## Cleanup integration test resources + kubectl delete namespace sonarr-operator-test --ignore-not-found + @echo "Integration test resources cleaned up" + +##@ Build + +.PHONY: build +build: ## Build release binary + cargo build --release + +.PHONY: build-debug +build-debug: ## Build debug binary + cargo build + +.PHONY: clean +clean: ## Clean build artifacts + cargo clean + rm -rf $(CRD_DIR) + +##@ CRD Management + +.PHONY: crds +crds: ## Generate CRD manifests (individual files) + @mkdir -p $(CRD_DIR) + @rm -f $(CRD_DIR)/*.yaml + cargo run --bin crdgen -- --split $(CRD_DIR) + +.PHONY: crds-combined +crds-combined: ## Generate all CRDs in a single file + @mkdir -p $(CRD_DIR) + cargo run --bin crdgen > $(CRD_DIR)/crds.yaml + @echo "CRDs generated in $(CRD_DIR)/crds.yaml" + +.PHONY: crds-single +crds-single: ## Generate a single CRD (use: make crds-single CRD=SonarrTag) + @mkdir -p $(CRD_DIR) + cargo run --bin crdgen -- --single $(CRD) > $(CRD_DIR)/$(shell echo $(CRD) | tr '[:upper:]' '[:lower:]').yaml + +##@ Documentation + +DOCS_DIR := docs/api +GOBIN := $(shell go env GOPATH)/bin + +.PHONY: docs +docs: crds ## Generate CRD documentation + @mkdir -p $(DOCS_DIR) + @echo "Generating CRD documentation..." + @if ! command -v crdoc >/dev/null 2>&1 && [ ! -f "$(GOBIN)/crdoc" ]; then \ + echo "Installing crdoc..."; \ + go install fybrik.io/crdoc@latest; \ + fi + @PATH="$(GOBIN):$$PATH" crdoc --resources $(CRD_DIR) --output $(DOCS_DIR)/crd-reference.md + @echo "Documentation generated in $(DOCS_DIR)/crd-reference.md" + +.PHONY: docs-html +docs-html: docs ## Generate HTML documentation (requires pandoc) + @command -v pandoc >/dev/null 2>&1 || { echo "ERROR: pandoc not installed"; exit 1; } + pandoc $(DOCS_DIR)/crd-reference.md -o $(DOCS_DIR)/crd-reference.html --standalone --toc --metadata title="Sonarr Operator CRD Reference" + @echo "HTML documentation generated in $(DOCS_DIR)/crd-reference.html" + +.PHONY: install +install: crds ## Install CRDs to cluster + kubectl apply -f $(CRD_DIR)/ + +.PHONY: uninstall +uninstall: ## Remove CRDs from cluster + kubectl delete -f $(CRD_DIR)/ --ignore-not-found + +##@ Running + +.PHONY: run +run: ## Run operator locally (requires kubeconfig) + RUST_LOG=info cargo run --bin sonarr-operator + +.PHONY: run-debug +run-debug: ## Run operator with debug logging + RUST_LOG=debug cargo run --bin sonarr-operator + +##@ Docker + +.PHONY: docker +docker: ## Build Docker image + docker build -t $(IMAGE_NAME):$(IMAGE_TAG) . + +.PHONY: docker-push +docker-push: docker ## Push Docker image to registry + docker push $(IMAGE_NAME):$(IMAGE_TAG) + +##@ Kubernetes Deployment + +.PHONY: deploy +deploy: install ## Deploy operator to cluster + kubectl apply -f deploy/namespace.yaml + kubectl apply -f deploy/rbac.yaml + kubectl apply -f deploy/deployment.yaml + +.PHONY: undeploy +undeploy: ## Remove operator from cluster + kubectl delete -f deploy/deployment.yaml --ignore-not-found + kubectl delete -f deploy/rbac.yaml --ignore-not-found + kubectl delete -f deploy/namespace.yaml --ignore-not-found + +##@ Testing with Kind + +.PHONY: kind-create +kind-create: ## Create a Kind cluster for testing + kind create cluster --name sonarr-operator-test + +.PHONY: kind-delete +kind-delete: ## Delete the Kind cluster + kind delete cluster --name sonarr-operator-test + +.PHONY: kind-load +kind-load: docker ## Load Docker image into Kind cluster + kind load docker-image $(IMAGE_NAME):$(IMAGE_TAG) --name sonarr-operator-test + +##@ End-to-End Testing + +.PHONY: e2e-setup +e2e-setup: kind-create install ## Setup E2E test environment with Sonarr + @echo "Setting up E2E test environment..." + chmod +x tests/e2e/fixtures/setup.sh + ./tests/e2e/fixtures/setup.sh + @echo "E2E environment ready" + +.PHONY: e2e-sonarr +e2e-sonarr: ## Deploy only Sonarr for E2E tests (use after kind-create install) + @echo "Deploying Sonarr..." + kubectl apply -f tests/e2e/fixtures/sonarr-deployment.yaml + kubectl wait --for=condition=available deployment/sonarr -n default --timeout=300s + kubectl wait --for=condition=ready pod -l app=sonarr -n default --timeout=300s + @echo "Waiting for Sonarr to initialize..." + sleep 60 + @echo "Extracting API key..." + @SONARR_POD=$$(kubectl get pod -l app=sonarr -n default -o jsonpath='{.items[0].metadata.name}'); \ + API_KEY=$$(kubectl exec $$SONARR_POD -n default -- cat /config/config.xml 2>/dev/null | grep -oP '(?<=)[^<]+'); \ + echo "API Key: $$API_KEY"; \ + kubectl create secret generic sonarr-api-key --from-literal=api-key="$$API_KEY" -n default --dry-run=client -o yaml | kubectl apply -f - + kubectl apply -f tests/e2e/fixtures/sonarr-instance.yaml + @echo "Sonarr deployed and configured" + +.PHONY: e2e +e2e: ## Run E2E tests (requires e2e-setup or e2e-sonarr first, operator running) + @echo "Running E2E tests..." + @if [ -z "$$SONARR_API_KEY" ]; then \ + echo "ERROR: SONARR_API_KEY environment variable not set"; \ + echo "Run: export SONARR_API_KEY=\$$(cat tests/e2e/fixtures/.sonarr-api-key)"; \ + exit 1; \ + fi + @if [ -z "$$SONARR_URL" ]; then \ + export SONARR_URL="http://localhost:8989"; \ + fi + cargo test --test e2e -- --ignored --test-threads=1 + +.PHONY: e2e-verbose +e2e-verbose: ## Run E2E tests with verbose output + @echo "Running E2E tests (verbose)..." + @if [ -z "$$SONARR_API_KEY" ]; then \ + echo "ERROR: SONARR_API_KEY environment variable not set"; \ + exit 1; \ + fi + RUST_LOG=debug cargo test --test e2e -- --ignored --test-threads=1 --nocapture + +.PHONY: e2e-port-forward +e2e-port-forward: ## Start port-forward to Sonarr (run in separate terminal) + @echo "Port-forwarding to Sonarr on localhost:8989..." + @echo "Press Ctrl+C to stop" + kubectl port-forward svc/sonarr 8989:8989 -n default + +.PHONY: e2e-cleanup +e2e-cleanup: ## Cleanup E2E test resources + chmod +x tests/e2e/fixtures/cleanup.sh + ./tests/e2e/fixtures/cleanup.sh + +.PHONY: e2e-full +e2e-full: e2e-setup ## Run full E2E test suite (setup + tests + cleanup) + @echo "Starting operator in background..." + RUST_LOG=info cargo run & + sleep 10 + @echo "Setting up port-forward..." + kubectl port-forward svc/sonarr 8989:8989 -n default & + sleep 5 + @echo "Running E2E tests..." + export SONARR_API_KEY=$$(cat tests/e2e/fixtures/.sonarr-api-key); \ + export SONARR_URL="http://localhost:8989"; \ + cargo test --test e2e -- --ignored --test-threads=1 || true + @echo "Cleaning up..." + pkill -f "cargo run" || true + pkill -f "port-forward" || true + $(MAKE) e2e-cleanup + +##@ Complete Workflows + +.PHONY: all +all: lint test build crds ## Run lint, test, build, and generate CRDs + +.PHONY: ci +ci: lint test build ## CI pipeline tasks diff --git a/README.md b/README.md index 45b5659..79a0093 100644 --- a/README.md +++ b/README.md @@ -1,79 +1,284 @@ -# Rust Kubernetes Operator +# Sonarr Kubernetes Operator -A Kubernetes operator written in Rust using the kube-rs library. +A Kubernetes operator for [Sonarr](https://sonarr.tv/) written in Rust using [kube-rs](https://kube.rs/). -## Prerequisites +This operator allows you to manage Sonarr resources (tags, root folders, quality profiles, indexers, download clients, notifications, and series) declaratively through Kubernetes Custom Resources. -- Rust 1.71+ -- Kubernetes cluster (local or remote) -- kubectl configured to access your cluster +## Features -## Building and Running +- **Declarative Configuration**: Define Sonarr resources as Kubernetes manifests +- **GitOps Ready**: Manage Sonarr configuration through version control +- **Multi-Instance Support**: Manage multiple Sonarr instances from a single operator +- **Automatic Synchronization**: Resources are continuously reconciled with Sonarr +- **Finalizers**: Clean up resources in Sonarr when Kubernetes resources are deleted -### Local Development +## Supported Resources + +| CRD | Description | +|-----|-------------| +| `Sonarr` | Connection to a Sonarr instance | +| `SonarrTag` | Manage tags for organizing series | +| `SonarrRootFolder` | Configure root folders for media storage | +| `SonarrQualityProfile` | Define quality profiles | +| `SonarrIndexer` | Configure indexers for searching | +| `SonarrDownloadClient` | Set up download clients (SABnzbd, qBittorrent, etc.) | +| `SonarrNotification` | Configure notifications (Discord, Telegram, etc.) | +| `SonarrSeries` | Add and manage TV series | + +## Quick Start + +### Prerequisites + +- Kubernetes cluster (1.25+) +- Rust toolchain (for building from source) +- A running Sonarr instance + +### Installation + +1. **Install CRDs** ```bash -# Build the project -cargo build +make crds +kubectl apply -f deploy/crds/crds.yaml +``` + +2. **Deploy the Operator** -# Run the operator locally -cargo run +```bash +# Build and push Docker image (adjust image name as needed) +make docker +docker tag sonarr-operator:latest your-registry/sonarr-operator:latest +docker push your-registry/sonarr-operator:latest -# Run with a specific namespace -cargo run -- --namespace=default +# Deploy to cluster +kubectl apply -f deploy/ ``` -### Running in Kubernetes +3. **Create a Sonarr Instance Reference** + +First, create a secret with your Sonarr API key: ```bash -# Apply the CRD -kubectl apply -f manifests/crd.yaml +kubectl create secret generic sonarr-api-key \ + --from-literal=api-key=YOUR_SONARR_API_KEY +``` -# Build and push the Docker image -docker build -t your-registry/rust-k8s-operator:latest . -docker push your-registry/rust-k8s-operator:latest +Then create the Sonarr instance reference: -# Deploy the operator (update the image reference in the deployment manifest) -kubectl apply -f manifests/deployment.yaml +```yaml +apiVersion: devopsarr.io/v1alpha1 +kind: Sonarr +metadata: + name: my-sonarr +spec: + url: "http://sonarr.example.com:8989" + apiKeySecretRef: + name: sonarr-api-key + key: api-key +``` + +4. **Create Resources** + +```yaml +apiVersion: devopsarr.io/v1alpha1 +kind: SonarrTag +metadata: + name: anime +spec: + sonarrInstanceRef: + name: my-sonarr + label: "anime" +--- +apiVersion: devopsarr.io/v1alpha1 +kind: SonarrRootFolder +metadata: + name: tv-shows +spec: + sonarrInstanceRef: + name: my-sonarr + path: "/media/tv" +``` + +## Usage Examples -# Create an example resource -kubectl apply -f manifests/example-cr.yaml +### Managing Tags + +```yaml +apiVersion: devopsarr.io/v1alpha1 +kind: SonarrTag +metadata: + name: documentary +spec: + sonarrInstanceRef: + name: my-sonarr + label: "documentary" ``` +### Adding a Series -## CRD Generation +```yaml +apiVersion: devopsarr.io/v1alpha1 +kind: SonarrSeries +metadata: + name: breaking-bad +spec: + sonarrInstanceRef: + name: my-sonarr + tvdbId: 81189 + qualityProfileId: 1 + rootFolderPath: "/media/tv" + monitored: true + seasonFolder: true + seriesType: Standard + monitor: All + searchOnAdd: true + tags: [] +``` -This project uses code-first CRD definition. The CRD YAML is generated from the Rust code to ensure they stay in sync. +### Configuring a Download Client -### Generating the CRD +```yaml +apiVersion: devopsarr.io/v1alpha1 +kind: SonarrDownloadClient +metadata: + name: qbittorrent +spec: + sonarrInstanceRef: + name: my-sonarr + name: "qBittorrent" + enable: true + priority: 1 + downloadClientType: QBittorrent + fields: + - name: host + value: "qbittorrent.example.com" + - name: port + value: "8080" + - name: username + value: "admin" + - name: password + secretRef: + name: qbittorrent-secret + key: password +``` + +### Setting Up Notifications + +```yaml +apiVersion: devopsarr.io/v1alpha1 +kind: SonarrNotification +metadata: + name: discord +spec: + sonarrInstanceRef: + name: my-sonarr + name: "Discord" + notificationType: Discord + triggers: + onGrab: true + onDownload: true + onUpgrade: true + onRename: false + onSeriesAdd: true + onSeriesDelete: false + onEpisodeFileDelete: false + onEpisodeFileDeleteForUpgrade: false + onHealthIssue: true + onHealthRestored: true + onApplicationUpdate: false + onManualInteractionRequired: false + includeHealthWarnings: false + tags: [] +``` + +## Building from Source + +```bash +# Clone the repository +git clone https://github.com/your-org/kubernetes-operator-sonarr.git +cd kubernetes-operator-sonarr + +# Build +make build + +# Run locally (requires kubeconfig) +make run +``` + +## Development ```bash -# Using cargo directly -cargo run --bin generate_crd +# Check code +make check + +# Run linters +make lint + +# Run tests +make test -# Or using the provided shell script -./generate-crd.sh +# Generate CRDs +make crds +``` + +## Testing + +See [docs/TESTING.md](docs/TESTING.md) for comprehensive testing instructions, including: + +- Unit testing +- Local development with Kind +- End-to-end testing with a real Sonarr instance +- CI/CD integration + +## Architecture -# Generate and apply to the cluster -./generate-crd.sh --apply ``` +┌─────────────────────────────────────────────────────────────┐ +│ Kubernetes Cluster │ +│ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ Sonarr CRDs │ │ Sonarr │ │ +│ │ - Sonarr │────▶│ Operator │ │ +│ │ - SonarrTag │ │ │ │ +│ │ - SonarrSeries │ └────────┬────────┘ │ +│ │ - ... │ │ │ +│ └─────────────────┘ │ API Calls │ +└───────────────────────────────────┼─────────────────────────┘ + │ + ▼ + ┌─────────────────┐ + │ Sonarr Server │ + │ (External) │ + └─────────────────┘ +``` + +## Configuration + +### Environment Variables -The generated CRD will be saved to `manifests/crd.yaml`. +| Variable | Description | Default | +|----------|-------------|---------| +| `RUST_LOG` | Log level (trace, debug, info, warn, error) | `info` | -### How it works +### Operator Flags -The CRD is defined in the Rust code using the `#[derive(CustomResource)]` macro from the kube-rs crate. The `generate_crd` binary uses the `CustomResourceExt` trait to generate the YAML representation of the CRD. +The operator currently does not require any command-line flags and uses the default kubeconfig or in-cluster configuration. -If you modify the CRD definition in the Rust code, make sure to regenerate the YAML manifest. +## Contributing -## Project Structure +Contributions are welcome! Please feel free to submit a Pull Request. -- `src/main.rs`: Entry point for the operator -- `src/crd/`: Custom resource definitions -- `src/controller/`: Reconciliation logic -- `src/error/`: Error types -- `manifests/`: Kubernetes manifests for deployment +1. Fork the repository +2. Create your feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'Add some amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request ## License -MIT \ No newline at end of file +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## Acknowledgments + +- [Sonarr](https://sonarr.tv/) - The PVR for Usenet and BitTorrent users +- [kube-rs](https://kube.rs/) - Rust client for Kubernetes +- [sonarr-rs](https://github.com/devopsarr/sonarr-rs) - Sonarr API client for Rust diff --git a/deploy/crds/autotag.yaml b/deploy/crds/autotag.yaml new file mode 100644 index 0000000..f70a9f8 --- /dev/null +++ b/deploy/crds/autotag.yaml @@ -0,0 +1,182 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: sonarrautotags.devopsarr.io +spec: + group: devopsarr.io + names: + categories: [] + kind: SonarrAutoTag + plural: sonarrautotags + shortNames: + - sat + singular: sonarrautotag + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.name + name: Name + type: string + - jsonPath: .spec.removeTagsAutomatically + name: RemoveAuto + type: boolean + - jsonPath: .status.id + name: ID + type: integer + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Auto-generated derived type for SonarrAutoTagSpec via `CustomResource` + properties: + spec: + description: |- + SonarrAutoTag represents an auto-tagging rule configuration in Sonarr + Auto-tagging automatically applies tags to series based on conditions + properties: + name: + description: Auto-tag rule name + type: string + removeTagsAutomatically: + default: false + description: Remove tags automatically when conditions no longer match + type: boolean + sonarrInstanceRef: + description: Reference to the SonarrInstance + properties: + name: + default: '' + description: Name of the SonarrInstance resource + type: string + namespace: + description: Namespace of the SonarrInstance (optional, defaults to same namespace) + nullable: true + type: string + type: object + specifications: + default: [] + description: Specifications (conditions) for this auto-tag rule + items: + properties: + fields: + default: + max: null + min: null + value: null + description: Fields/values for this specification + properties: + max: + description: Maximum value (for year specifications) + format: int32 + nullable: true + type: integer + min: + description: Minimum value (for year specifications) + format: int32 + nullable: true + type: integer + value: + description: Value for the specification (path, genre, network, etc.) + nullable: true + type: string + type: object + implementation: + description: Specification type/implementation + enum: + - rootFolderSpecification + - genreSpecification + - yearSpecification + - seriesTypeSpecification + - qualityProfileSpecification + - networkSpecification + - originalLanguageSpecification + - tagSpecification + type: string + name: + description: Specification name + type: string + negate: + default: false + description: Negate this condition + type: boolean + required: + default: true + description: This condition is required + type: boolean + required: + - implementation + - name + type: object + type: array + tags: + default: [] + description: Tags to apply when conditions match + items: + format: int32 + type: integer + type: array + required: + - name + - sonarrInstanceRef + type: object + status: + nullable: true + properties: + conditions: + default: [] + description: Current conditions + items: + description: Condition contains details for one aspect of the current state of this API Resource. + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating details about the transition. This may be an empty string. + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance. + format: int64 + type: integer + reason: + description: reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty. + type: string + status: + description: status of the condition, one of True, False, Unknown. + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + id: + description: Sonarr Auto Tag ID + format: int32 + nullable: true + type: integer + observedGeneration: + default: 0 + description: Observed generation + format: int64 + type: integer + type: object + required: + - spec + title: SonarrAutoTag + type: object + served: true + storage: true + subresources: + status: {} diff --git a/deploy/crds/customformat.yaml b/deploy/crds/customformat.yaml new file mode 100644 index 0000000..f6395f8 --- /dev/null +++ b/deploy/crds/customformat.yaml @@ -0,0 +1,173 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: sonarrcustomformats.devopsarr.io +spec: + group: devopsarr.io + names: + categories: [] + kind: SonarrCustomFormat + plural: sonarrcustomformats + shortNames: + - scf + singular: sonarrcustomformat + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.name + name: Name + type: string + - jsonPath: .status.id + name: ID + type: integer + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Auto-generated derived type for SonarrCustomFormatSpec via `CustomResource` + properties: + spec: + description: |- + SonarrCustomFormat represents a custom format configuration in Sonarr + Custom formats are used to score releases based on various criteria + properties: + includeCustomFormatWhenRenaming: + default: false + description: Include custom format name when renaming files + type: boolean + name: + description: Custom format name + type: string + sonarrInstanceRef: + description: Reference to the SonarrInstance + properties: + name: + default: '' + description: Name of the SonarrInstance resource + type: string + namespace: + description: Namespace of the SonarrInstance (optional, defaults to same namespace) + nullable: true + type: string + type: object + specifications: + default: [] + description: Specifications (conditions) for this custom format + items: + properties: + fields: + default: + max: null + min: null + value: null + description: Fields/values for this specification + properties: + max: + description: Maximum value (for size specifications) + format: double + nullable: true + type: number + min: + description: Minimum value (for size specifications) + format: double + nullable: true + type: number + value: + description: Value for the specification (regex pattern, source type, etc.) + nullable: true + type: string + type: object + implementation: + description: Specification type/implementation + enum: + - releaseTitleSpecification + - sourceSpecification + - resolutionSpecification + - qualityModifierSpecification + - sizeSpecification + - indexerFlagSpecification + - languageSpecification + - releaseGroupSpecification + - editionSpecification + type: string + name: + description: Specification name + type: string + negate: + default: false + description: Negate this condition + type: boolean + required: + default: true + description: This condition is required + type: boolean + required: + - implementation + - name + type: object + type: array + required: + - name + - sonarrInstanceRef + type: object + status: + nullable: true + properties: + conditions: + default: [] + description: Current conditions + items: + description: Condition contains details for one aspect of the current state of this API Resource. + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating details about the transition. This may be an empty string. + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance. + format: int64 + type: integer + reason: + description: reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty. + type: string + status: + description: status of the condition, one of True, False, Unknown. + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + id: + description: Sonarr Custom Format ID + format: int32 + nullable: true + type: integer + observedGeneration: + default: 0 + description: Observed generation + format: int64 + type: integer + type: object + required: + - spec + title: SonarrCustomFormat + type: object + served: true + storage: true + subresources: + status: {} diff --git a/deploy/crds/delayprofile.yaml b/deploy/crds/delayprofile.yaml new file mode 100644 index 0000000..c7fcea8 --- /dev/null +++ b/deploy/crds/delayprofile.yaml @@ -0,0 +1,165 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: sonarrdelayprofiles.devopsarr.io +spec: + group: devopsarr.io + names: + categories: [] + kind: SonarrDelayProfile + plural: sonarrdelayprofiles + shortNames: + - sdp + singular: sonarrdelayprofile + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.preferredProtocol + name: Protocol + type: string + - jsonPath: .spec.usenetDelay + name: UsenetDelay + type: integer + - jsonPath: .spec.torrentDelay + name: TorrentDelay + type: integer + - jsonPath: .status.id + name: ID + type: integer + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Auto-generated derived type for SonarrDelayProfileSpec via `CustomResource` + properties: + spec: + description: |- + SonarrDelayProfile represents a delay profile configuration in Sonarr + Delay profiles control how long Sonarr waits before grabbing a release + properties: + bypassIfAboveCustomFormatScore: + default: false + description: Bypass delay if above custom format score + type: boolean + bypassIfHighestQuality: + default: false + description: Bypass delay if highest quality + type: boolean + enableTorrent: + default: true + description: Enable Torrent downloads + type: boolean + enableUsenet: + default: true + description: Enable Usenet downloads + type: boolean + minimumCustomFormatScore: + default: 0 + description: Minimum custom format score to bypass delay + format: int32 + type: integer + order: + default: 0 + description: Order of this profile (lower = higher priority) + format: int32 + type: integer + preferredProtocol: + default: usenet + description: Preferred download protocol + enum: + - usenet + - torrent + type: string + sonarrInstanceRef: + description: Reference to the SonarrInstance + properties: + name: + default: '' + description: Name of the SonarrInstance resource + type: string + namespace: + description: Namespace of the SonarrInstance (optional, defaults to same namespace) + nullable: true + type: string + type: object + tags: + default: [] + description: Tags to apply this delay profile to + items: + format: int32 + type: integer + type: array + torrentDelay: + default: 0 + description: Delay for Torrents in minutes + format: int32 + type: integer + usenetDelay: + default: 0 + description: Delay for Usenet in minutes + format: int32 + type: integer + required: + - sonarrInstanceRef + type: object + status: + nullable: true + properties: + conditions: + default: [] + description: Current conditions + items: + description: Condition contains details for one aspect of the current state of this API Resource. + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating details about the transition. This may be an empty string. + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance. + format: int64 + type: integer + reason: + description: reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty. + type: string + status: + description: status of the condition, one of True, False, Unknown. + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + id: + description: Sonarr Delay Profile ID + format: int32 + nullable: true + type: integer + observedGeneration: + default: 0 + description: Observed generation + format: int64 + type: integer + type: object + required: + - spec + title: SonarrDelayProfile + type: object + served: true + storage: true + subresources: + status: {} diff --git a/deploy/crds/downloadclient.yaml b/deploy/crds/downloadclient.yaml new file mode 100644 index 0000000..45d388d --- /dev/null +++ b/deploy/crds/downloadclient.yaml @@ -0,0 +1,289 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: sonarrdownloadclients.devopsarr.io +spec: + group: devopsarr.io + names: + categories: [] + kind: SonarrDownloadClient + plural: sonarrdownloadclients + shortNames: + - sdc + singular: sonarrdownloadclient + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.name + name: Name + type: string + - jsonPath: .spec.downloadClientType + name: Type + type: string + - jsonPath: .status.id + name: ID + type: integer + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Auto-generated derived type for SonarrDownloadClientSpec via `CustomResource` + properties: + spec: + description: |- + SonarrDownloadClient represents a download client configuration in Sonarr + Download clients are used to download releases (qBittorrent, Transmission, SABnzbd, etc.) + properties: + config: + description: Download client configuration + properties: + addPaused: + default: false + description: Add paused + type: boolean + apiKeySecretRef: + description: API key from secret (for some clients) + nullable: true + properties: + key: + description: Key in the secret + type: string + name: + description: Name of the secret + type: string + required: + - key + - name + type: object + firstAndLast: + default: false + description: First and last (for qBittorrent) + type: boolean + host: + description: Host address + nullable: true + type: string + initialState: + description: 'Initial state (for qBittorrent: 0 = Start, 1 = ForceStart, 2 = Pause)' + format: int32 + nullable: true + type: integer + nzbFolder: + description: NZB folder (for blackhole) + nullable: true + type: string + olderTvPriority: + description: Older TV priority (0 = Last, 1 = First) + format: int32 + nullable: true + type: integer + passwordSecretRef: + description: Password from secret + nullable: true + properties: + key: + description: Key in the secret + type: string + name: + description: Name of the secret + type: string + required: + - key + - name + type: object + port: + description: Port number + format: int32 + nullable: true + type: integer + recentTvPriority: + description: Recent TV priority (0 = Last, 1 = First) + format: int32 + nullable: true + type: integer + rpcPath: + description: RPC path (for Aria2) + nullable: true + type: string + saveMagnetFiles: + default: false + description: Save magnet files (for blackhole) + type: boolean + secretTokenSecretRef: + description: Secret token (for Aria2) + nullable: true + properties: + key: + description: Key in the secret + type: string + name: + description: Name of the secret + type: string + required: + - key + - name + type: object + sequentialOrder: + default: false + description: Sequential order (for qBittorrent) + type: boolean + strmFolder: + description: Strm folder (for pneumatic) + nullable: true + type: string + torrentFolder: + description: Torrent folder (for blackhole) + nullable: true + type: string + tvCategory: + description: TV category + nullable: true + type: string + tvDirectory: + description: TV directory + nullable: true + type: string + urlBase: + description: URL base path + nullable: true + type: string + useSsl: + default: false + description: Use SSL + type: boolean + username: + description: Username + nullable: true + type: string + watchFolder: + description: Watch folder (for blackhole) + nullable: true + type: string + type: object + downloadClientType: + description: Download client type + enum: + - Aria2 + - Deluge + - Flood + - Hadouken + - Nzbget + - Nzbvortex + - Pneumatic + - QBittorrent + - RTorrent + - Sabnzbd + - TorrentBlackhole + - TorrentDownloadStation + - Transmission + - UsenetBlackhole + - UsenetDownloadStation + - UTorrent + - Vuze + type: string + enable: + default: true + description: Enable this download client + type: boolean + name: + description: Download client name + type: string + priority: + default: 1 + description: Priority for this download client + format: int32 + type: integer + removeCompletedDownloads: + default: true + description: Remove completed downloads + type: boolean + removeFailedDownloads: + default: true + description: Remove failed downloads + type: boolean + sonarrInstanceRef: + description: Reference to the SonarrInstance + properties: + name: + default: '' + description: Name of the SonarrInstance resource + type: string + namespace: + description: Namespace of the SonarrInstance (optional, defaults to same namespace) + nullable: true + type: string + type: object + tags: + default: [] + description: Tags for this download client + items: + format: int32 + type: integer + type: array + required: + - config + - downloadClientType + - name + - sonarrInstanceRef + type: object + status: + nullable: true + properties: + conditions: + default: [] + description: Current conditions + items: + description: Condition contains details for one aspect of the current state of this API Resource. + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating details about the transition. This may be an empty string. + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance. + format: int64 + type: integer + reason: + description: reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty. + type: string + status: + description: status of the condition, one of True, False, Unknown. + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + id: + description: Sonarr Download Client ID + format: int32 + nullable: true + type: integer + observedGeneration: + default: 0 + description: Observed generation + format: int64 + type: integer + type: object + required: + - spec + title: SonarrDownloadClient + type: object + served: true + storage: true + subresources: + status: {} diff --git a/deploy/crds/downloadclientconfig.yaml b/deploy/crds/downloadclientconfig.yaml new file mode 100644 index 0000000..7f1cca0 --- /dev/null +++ b/deploy/crds/downloadclientconfig.yaml @@ -0,0 +1,121 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: sonarrdownloadclientconfigs.devopsarr.io +spec: + group: devopsarr.io + names: + categories: [] + kind: SonarrDownloadClientConfig + plural: sonarrdownloadclientconfigs + shortNames: + - sdcc + singular: sonarrdownloadclientconfig + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.sonarrInstanceRef.name + name: Instance + type: string + - jsonPath: .spec.enableCompletedDownloadHandling + name: Completed Handling + type: boolean + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Auto-generated derived type for SonarrDownloadClientConfigSpec via `CustomResource` + properties: + spec: + description: |- + SonarrDownloadClientConfig configures global download client settings for a Sonarr instance. + Only one SonarrDownloadClientConfig per Sonarr instance is allowed. + Note: This is different from SonarrDownloadClient which configures individual download clients. + properties: + autoRedownloadFailed: + description: Automatically redownload failed releases + nullable: true + type: boolean + autoRedownloadFailedFromInteractiveSearch: + description: Automatically redownload failed releases from interactive search + nullable: true + type: boolean + downloadClientWorkingFolders: + description: Working folders for download client (container path mapping) + nullable: true + type: string + enableCompletedDownloadHandling: + description: Enable completed download handling + nullable: true + type: boolean + sonarrInstanceRef: + description: Reference to the Sonarr instance + properties: + name: + default: '' + description: Name of the SonarrInstance resource + type: string + namespace: + description: Namespace of the SonarrInstance (optional, defaults to same namespace) + nullable: true + type: string + type: object + required: + - sonarrInstanceRef + type: object + status: + nullable: true + properties: + conditions: + default: [] + description: Current conditions + items: + description: Condition contains details for one aspect of the current state of this API Resource. + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating details about the transition. This may be an empty string. + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance. + format: int64 + type: integer + reason: + description: reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty. + type: string + status: + description: status of the condition, one of True, False, Unknown. + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + observedGeneration: + default: 0 + description: Observed generation + format: int64 + type: integer + type: object + required: + - spec + title: SonarrDownloadClientConfig + type: object + served: true + storage: true + subresources: + status: {} diff --git a/deploy/crds/importlist.yaml b/deploy/crds/importlist.yaml new file mode 100644 index 0000000..1f1bc48 --- /dev/null +++ b/deploy/crds/importlist.yaml @@ -0,0 +1,262 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: sonarrimportlists.devopsarr.io +spec: + group: devopsarr.io + names: + categories: [] + kind: SonarrImportList + plural: sonarrimportlists + shortNames: + - sil + singular: sonarrimportlist + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.name + name: Name + type: string + - jsonPath: .spec.listType + name: Type + type: string + - jsonPath: .status.id + name: ID + type: integer + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Auto-generated derived type for SonarrImportListSpec via `CustomResource` + properties: + spec: + description: |- + SonarrImportList represents an import list configuration in Sonarr + Import lists automatically add series from external sources (Trakt, Plex, etc.) + properties: + config: + default: + accessToken: null + apiKey: null + authUser: null + baseUrl: null + languageProfileId: null + listId: null + listname: null + profileIds: [] + tagIds: [] + traktListType: null + username: null + description: Import list configuration + properties: + accessToken: + description: Access token (for Trakt/Plex) + nullable: true + type: string + apiKey: + description: API key (for Sonarr import) + nullable: true + type: string + authUser: + description: Auth user (for Trakt) + nullable: true + type: string + baseUrl: + description: Base URL (for Sonarr import) + nullable: true + type: string + languageProfileId: + description: Language profile ID (deprecated in v4) + format: int32 + nullable: true + type: integer + listId: + description: List ID + nullable: true + type: string + listname: + description: List name/ID + nullable: true + type: string + profileIds: + default: [] + description: Profile IDs (for Sonarr import) + items: + format: int32 + type: integer + type: array + tagIds: + default: [] + description: Tag IDs (for Sonarr import) + items: + format: int32 + type: integer + type: array + traktListType: + description: Trakt list type + format: int32 + nullable: true + type: integer + username: + description: Username (for various services) + nullable: true + type: string + type: object + enableAutomaticAdd: + default: true + description: Enable automatic add + type: boolean + listOrder: + default: 0 + description: List order + format: int32 + type: integer + listType: + description: Import list type/implementation + enum: + - sonarrImport + - traktListImport + - traktUserImport + - traktPopularImport + - plexImport + - imdbListImport + - customImport + - simklImport + - aniListImport + - myAnimeListImport + type: string + monitorNewItems: + default: all + description: Monitor new items + enum: + - all + - none + type: string + name: + description: Import list name + type: string + qualityProfileId: + description: Quality profile ID to use + format: int32 + type: integer + rootFolderPath: + description: Root folder path for imported series + type: string + searchForMissingEpisodes: + default: false + description: Search for missing episodes when adding + type: boolean + seasonFolder: + default: true + description: Use season folders + type: boolean + seriesType: + default: standard + description: Series type + enum: + - standard + - daily + - anime + type: string + shouldMonitor: + default: all + description: Monitor type for imported series + enum: + - all + - future + - missing + - existing + - firstSeason + - latestSeason + - pilot + - monitorSpecials + - unmonitorSpecials + - none + type: string + sonarrInstanceRef: + description: Reference to the SonarrInstance + properties: + name: + default: '' + description: Name of the SonarrInstance resource + type: string + namespace: + description: Namespace of the SonarrInstance (optional, defaults to same namespace) + nullable: true + type: string + type: object + tags: + default: [] + description: Tags for imported series + items: + format: int32 + type: integer + type: array + required: + - listType + - name + - qualityProfileId + - rootFolderPath + - sonarrInstanceRef + type: object + status: + nullable: true + properties: + conditions: + default: [] + description: Current conditions + items: + description: Condition contains details for one aspect of the current state of this API Resource. + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating details about the transition. This may be an empty string. + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance. + format: int64 + type: integer + reason: + description: reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty. + type: string + status: + description: status of the condition, one of True, False, Unknown. + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + id: + description: Sonarr Import List ID + format: int32 + nullable: true + type: integer + observedGeneration: + default: 0 + description: Observed generation + format: int64 + type: integer + type: object + required: + - spec + title: SonarrImportList + type: object + served: true + storage: true + subresources: + status: {} diff --git a/deploy/crds/indexer.yaml b/deploy/crds/indexer.yaml new file mode 100644 index 0000000..172ac7d --- /dev/null +++ b/deploy/crds/indexer.yaml @@ -0,0 +1,254 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: sonarrindexers.devopsarr.io +spec: + group: devopsarr.io + names: + categories: [] + kind: SonarrIndexer + plural: sonarrindexers + shortNames: + - sidx + singular: sonarrindexer + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.name + name: Name + type: string + - jsonPath: .spec.indexerType + name: Type + type: string + - jsonPath: .status.id + name: ID + type: integer + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Auto-generated derived type for SonarrIndexerSpec via `CustomResource` + properties: + spec: + description: |- + SonarrIndexer represents an indexer configuration in Sonarr + Indexers are sources for finding releases (Newznab, Torznab, etc.) + properties: + config: + description: Indexer-specific configuration + properties: + additionalParameters: + description: Additional parameters + nullable: true + type: string + animeCategories: + default: [] + description: Anime categories + items: + format: int32 + type: integer + type: array + animeStandardFormatSearch: + default: false + description: Search anime in standard format + type: boolean + apiKey: + description: API key (can reference a secret) + nullable: true + type: string + apiKeySecretRef: + description: API key from secret reference + nullable: true + properties: + key: + description: Key in the secret + type: string + name: + description: Name of the secret + type: string + required: + - key + - name + type: object + apiPath: + description: 'API path (default: /api)' + nullable: true + type: string + baseUrl: + description: Base URL for the indexer + nullable: true + type: string + categories: + default: [] + description: Categories to search + items: + format: int32 + type: integer + type: array + cookie: + description: Cookie (for some indexers) + nullable: true + type: string + minimumSeeders: + description: Minimum seeders (for torrent indexers) + format: int32 + nullable: true + type: integer + passkey: + description: Passkey (for some indexers) + nullable: true + type: string + passwordSecretRef: + description: Password secret reference (for some indexers) + nullable: true + properties: + key: + description: Key in the secret + type: string + name: + description: Name of the secret + type: string + required: + - key + - name + type: object + seedRatio: + description: Seed ratio (for torrent indexers) + format: double + nullable: true + type: number + seedTime: + description: Seed time (for torrent indexers) + format: int32 + nullable: true + type: integer + username: + description: Username (for some indexers) + nullable: true + type: string + type: object + downloadClientId: + description: Download client ID to use + format: int32 + nullable: true + type: integer + enableAutomaticSearch: + default: true + description: Enable automatic search + type: boolean + enableInteractiveSearch: + default: true + description: Enable interactive search + type: boolean + enableRss: + default: true + description: Enable RSS feeds + type: boolean + indexerType: + description: Indexer type (Newznab, Torznab, etc.) + enum: + - newznab + - torznab + - fanzub + - broadcasthenet + - filelist + - hdbits + - iptorrents + - nyaa + - torrentrss + - torrentleech + type: string + name: + description: Indexer name + type: string + priority: + default: 25 + description: Priority for this indexer + format: int32 + type: integer + sonarrInstanceRef: + description: Reference to the SonarrInstance + properties: + name: + default: '' + description: Name of the SonarrInstance resource + type: string + namespace: + description: Namespace of the SonarrInstance (optional, defaults to same namespace) + nullable: true + type: string + type: object + tags: + default: [] + description: Tags for this indexer + items: + format: int32 + type: integer + type: array + required: + - config + - indexerType + - name + - sonarrInstanceRef + type: object + status: + nullable: true + properties: + conditions: + default: [] + description: Current conditions + items: + description: Condition contains details for one aspect of the current state of this API Resource. + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating details about the transition. This may be an empty string. + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance. + format: int64 + type: integer + reason: + description: reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty. + type: string + status: + description: status of the condition, one of True, False, Unknown. + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + id: + description: Sonarr Indexer ID + format: int32 + nullable: true + type: integer + observedGeneration: + default: 0 + description: Observed generation + format: int64 + type: integer + type: object + required: + - spec + title: SonarrIndexer + type: object + served: true + storage: true + subresources: + status: {} diff --git a/deploy/crds/indexerconfig.yaml b/deploy/crds/indexerconfig.yaml new file mode 100644 index 0000000..8e341d5 --- /dev/null +++ b/deploy/crds/indexerconfig.yaml @@ -0,0 +1,125 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: sonarrindexerconfigs.devopsarr.io +spec: + group: devopsarr.io + names: + categories: [] + kind: SonarrIndexerConfig + plural: sonarrindexerconfigs + shortNames: + - sic + singular: sonarrindexerconfig + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.sonarrInstanceRef.name + name: Instance + type: string + - jsonPath: .spec.rssSyncInterval + name: RSS Interval + type: integer + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Auto-generated derived type for SonarrIndexerConfigSpec via `CustomResource` + properties: + spec: + description: |- + SonarrIndexerConfig configures global indexer settings for a Sonarr instance. + Only one SonarrIndexerConfig per Sonarr instance is allowed. + Note: This is different from SonarrIndexer which configures individual indexers. + properties: + maximumSize: + description: Maximum release size in MB (0 = unlimited) + format: int32 + nullable: true + type: integer + minimumAge: + description: Minimum age in minutes before downloading (usenet) + format: int32 + nullable: true + type: integer + retention: + description: Retention in days (0 = unlimited) + format: int32 + nullable: true + type: integer + rssSyncInterval: + description: RSS sync interval in minutes (0 = disabled, minimum 10) + format: int32 + nullable: true + type: integer + sonarrInstanceRef: + description: Reference to the Sonarr instance + properties: + name: + default: '' + description: Name of the SonarrInstance resource + type: string + namespace: + description: Namespace of the SonarrInstance (optional, defaults to same namespace) + nullable: true + type: string + type: object + required: + - sonarrInstanceRef + type: object + status: + nullable: true + properties: + conditions: + default: [] + description: Current conditions + items: + description: Condition contains details for one aspect of the current state of this API Resource. + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating details about the transition. This may be an empty string. + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance. + format: int64 + type: integer + reason: + description: reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty. + type: string + status: + description: status of the condition, one of True, False, Unknown. + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + observedGeneration: + default: 0 + description: Observed generation + format: int64 + type: integer + type: object + required: + - spec + title: SonarrIndexerConfig + type: object + served: true + storage: true + subresources: + status: {} diff --git a/deploy/crds/languageprofile.yaml b/deploy/crds/languageprofile.yaml new file mode 100644 index 0000000..01c5abe --- /dev/null +++ b/deploy/crds/languageprofile.yaml @@ -0,0 +1,245 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: sonarrlanguageprofiles.devopsarr.io +spec: + group: devopsarr.io + names: + categories: [] + kind: SonarrLanguageProfile + plural: sonarrlanguageprofiles + shortNames: + - slp + singular: sonarrlanguageprofile + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.name + name: Name + type: string + - jsonPath: .spec.cutoffLanguage + name: Cutoff + type: string + - jsonPath: .status.id + name: ID + type: integer + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Auto-generated derived type for SonarrLanguageProfileSpec via `CustomResource` + properties: + spec: + description: |- + SonarrLanguageProfile represents a language profile configuration in Sonarr + Language profiles define preferred languages for downloading series + Note: Deprecated in Sonarr v4, replaced by per-series language selection + properties: + cutoffLanguage: + description: Cutoff language - stop upgrading when this language is reached + enum: + - Unknown + - English + - French + - Spanish + - German + - Italian + - Danish + - Dutch + - Japanese + - Icelandic + - Chinese + - Russian + - Polish + - Vietnamese + - Swedish + - Norwegian + - Finnish + - Turkish + - Portuguese + - Flemish + - Greek + - Korean + - Hungarian + - Hebrew + - Lithuanian + - Czech + - Hindi + - Romanian + - Thai + - Bulgarian + - PortugueseBrazil + - Arabic + - Ukrainian + - Persian + - Bengali + - Slovak + - Latvian + - SpanishLatino + - Catalan + - Croatian + - Serbian + - Bosnian + - Estonian + - Tamil + - Indonesian + - Telugu + - Macedonian + - Slovenian + - Malay + - Original + - Any + type: string + languages: + description: Ordered list of languages (first = highest priority) + items: + properties: + allowed: + default: true + description: Whether this language is allowed + type: boolean + language: + description: Language + enum: + - Unknown + - English + - French + - Spanish + - German + - Italian + - Danish + - Dutch + - Japanese + - Icelandic + - Chinese + - Russian + - Polish + - Vietnamese + - Swedish + - Norwegian + - Finnish + - Turkish + - Portuguese + - Flemish + - Greek + - Korean + - Hungarian + - Hebrew + - Lithuanian + - Czech + - Hindi + - Romanian + - Thai + - Bulgarian + - PortugueseBrazil + - Arabic + - Ukrainian + - Persian + - Bengali + - Slovak + - Latvian + - SpanishLatino + - Catalan + - Croatian + - Serbian + - Bosnian + - Estonian + - Tamil + - Indonesian + - Telugu + - Macedonian + - Slovenian + - Malay + - Original + - Any + type: string + required: + - language + type: object + type: array + name: + description: Language profile name + type: string + sonarrInstanceRef: + description: Reference to the SonarrInstance + properties: + name: + default: '' + description: Name of the SonarrInstance resource + type: string + namespace: + description: Namespace of the SonarrInstance (optional, defaults to same namespace) + nullable: true + type: string + type: object + upgradeAllowed: + default: false + description: Allow upgrades to better quality languages + type: boolean + required: + - cutoffLanguage + - languages + - name + - sonarrInstanceRef + type: object + status: + nullable: true + properties: + conditions: + default: [] + description: Current conditions + items: + description: Condition contains details for one aspect of the current state of this API Resource. + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating details about the transition. This may be an empty string. + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance. + format: int64 + type: integer + reason: + description: reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty. + type: string + status: + description: status of the condition, one of True, False, Unknown. + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + id: + description: Sonarr Language Profile ID + format: int32 + nullable: true + type: integer + observedGeneration: + default: 0 + description: Observed generation + format: int64 + type: integer + type: object + required: + - spec + title: SonarrLanguageProfile + type: object + served: true + storage: true + subresources: + status: {} diff --git a/deploy/crds/mediamanagementconfig.yaml b/deploy/crds/mediamanagementconfig.yaml new file mode 100644 index 0000000..82e43e4 --- /dev/null +++ b/deploy/crds/mediamanagementconfig.yaml @@ -0,0 +1,203 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: sonarrmediamanagementconfigs.devopsarr.io +spec: + group: devopsarr.io + names: + categories: [] + kind: SonarrMediaManagementConfig + plural: sonarrmediamanagementconfigs + shortNames: + - smmc + singular: sonarrmediamanagementconfig + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.sonarrInstanceRef.name + name: Instance + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Auto-generated derived type for SonarrMediaManagementConfigSpec via `CustomResource` + properties: + spec: + description: |- + SonarrMediaManagementConfig configures media management settings for a Sonarr instance. + Only one SonarrMediaManagementConfig per Sonarr instance is allowed. + properties: + autoUnmonitorPreviouslyDownloadedEpisodes: + description: Auto unmonitor previously downloaded episodes when marked as deleted + nullable: true + type: boolean + chmodFolder: + description: chmod folder permissions (e.g., "755") + nullable: true + type: string + chownGroup: + description: chown group + nullable: true + type: string + copyUsingHardlinks: + description: Use hardlinks instead of copy when possible + nullable: true + type: boolean + createEmptySeriesFolders: + description: Create empty series folders during disk scan + nullable: true + type: boolean + deleteEmptyFolders: + description: Delete empty series and season folders during disk scan + nullable: true + type: boolean + downloadPropersAndRepacks: + description: 'Download propers and repacks: DoNotPrefer, PreferAndUpgrade, DoNotUpgrade' + enum: + - DoNotPrefer + - PreferAndUpgrade + - DoNotUpgrade + - null + nullable: true + type: string + enableMediaInfo: + description: Enable media info scanning + nullable: true + type: boolean + episodeTitleRequired: + description: 'Episode title required: Always, BulkSeasonReleases, Never' + enum: + - Always + - BulkSeasonReleases + - Never + - null + nullable: true + type: string + extraFileExtensions: + description: Extra file extensions to import (e.g., "srt,sub") + nullable: true + type: string + fileDate: + description: 'File date to use: None, LocalAirDate, UtcAirDate' + enum: + - None + - LocalAirDate + - UtcAirDate + - null + nullable: true + type: string + importExtraFiles: + description: Import extra files (subtitles, etc.) + nullable: true + type: boolean + minimumFreeSpaceWhenImporting: + description: Minimum free space when importing (MB) + format: int32 + nullable: true + type: integer + recycleBin: + description: Recycle bin path (empty to disable) + nullable: true + type: string + recycleBinCleanupDays: + description: Days to keep files in recycle bin before cleaning (0 to disable) + format: int32 + nullable: true + type: integer + rescanAfterRefresh: + description: 'Rescan series folder after refresh: Always, AfterManual, Never' + enum: + - Always + - AfterManual + - Never + - null + nullable: true + type: string + scriptImportPath: + description: Script import path + nullable: true + type: string + setPermissionsLinux: + description: Set permissions on Linux/macOS + nullable: true + type: boolean + skipFreeSpaceCheckWhenImporting: + description: Skip free space check when importing + nullable: true + type: boolean + sonarrInstanceRef: + description: Reference to the Sonarr instance + properties: + name: + default: '' + description: Name of the SonarrInstance resource + type: string + namespace: + description: Namespace of the SonarrInstance (optional, defaults to same namespace) + nullable: true + type: string + type: object + useScriptImport: + description: Use script for importing + nullable: true + type: boolean + required: + - sonarrInstanceRef + type: object + status: + nullable: true + properties: + conditions: + default: [] + description: Current conditions + items: + description: Condition contains details for one aspect of the current state of this API Resource. + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating details about the transition. This may be an empty string. + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance. + format: int64 + type: integer + reason: + description: reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty. + type: string + status: + description: status of the condition, one of True, False, Unknown. + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + observedGeneration: + default: 0 + description: Observed generation + format: int64 + type: integer + type: object + required: + - spec + title: SonarrMediaManagementConfig + type: object + served: true + storage: true + subresources: + status: {} diff --git a/deploy/crds/metadata.yaml b/deploy/crds/metadata.yaml new file mode 100644 index 0000000..7d729c3 --- /dev/null +++ b/deploy/crds/metadata.yaml @@ -0,0 +1,173 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: sonarrmetadatas.devopsarr.io +spec: + group: devopsarr.io + names: + categories: [] + kind: SonarrMetadata + plural: sonarrmetadatas + shortNames: + - smeta + singular: sonarrmetadata + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.name + name: Name + type: string + - jsonPath: .spec.metadataType + name: Type + type: string + - jsonPath: .spec.enable + name: Enabled + type: boolean + - jsonPath: .status.id + name: ID + type: integer + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Auto-generated derived type for SonarrMetadataSpec via `CustomResource` + properties: + spec: + description: |- + SonarrMetadata represents a metadata consumer configuration in Sonarr + Metadata consumers write metadata files for media managers (Kodi, Plex, etc.) + properties: + config: + default: + episodeImages: false + episodeMetadata: false + seasonImages: false + seriesImages: false + seriesMetadata: false + seriesMetadataUrl: false + description: Metadata-specific configuration + properties: + episodeImages: + default: false + description: Write episode images (thumbnails) + type: boolean + episodeMetadata: + default: true + description: Write episode metadata (episode.nfo) + type: boolean + seasonImages: + default: true + description: Write season images + type: boolean + seriesImages: + default: true + description: Write series images (poster, banner, fanart) + type: boolean + seriesMetadata: + default: true + description: Write series metadata (series.nfo) + type: boolean + seriesMetadataUrl: + default: false + description: Write series metadata URL (deprecated) + type: boolean + type: object + enable: + default: true + description: Enable this metadata consumer + type: boolean + metadataType: + description: Metadata type/implementation + enum: + - xbmcMetadata + - roksboxMetadata + - wdtvMetadata + type: string + name: + description: Metadata consumer name + type: string + sonarrInstanceRef: + description: Reference to the SonarrInstance + properties: + name: + default: '' + description: Name of the SonarrInstance resource + type: string + namespace: + description: Namespace of the SonarrInstance (optional, defaults to same namespace) + nullable: true + type: string + type: object + tags: + default: [] + description: Tags for this metadata consumer + items: + format: int32 + type: integer + type: array + required: + - metadataType + - name + - sonarrInstanceRef + type: object + status: + nullable: true + properties: + conditions: + default: [] + description: Current conditions + items: + description: Condition contains details for one aspect of the current state of this API Resource. + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating details about the transition. This may be an empty string. + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance. + format: int64 + type: integer + reason: + description: reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty. + type: string + status: + description: status of the condition, one of True, False, Unknown. + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + id: + description: Sonarr Metadata ID + format: int32 + nullable: true + type: integer + observedGeneration: + default: 0 + description: Observed generation + format: int64 + type: integer + type: object + required: + - spec + title: SonarrMetadata + type: object + served: true + storage: true + subresources: + status: {} diff --git a/deploy/crds/namingconfig.yaml b/deploy/crds/namingconfig.yaml new file mode 100644 index 0000000..36cb92c --- /dev/null +++ b/deploy/crds/namingconfig.yaml @@ -0,0 +1,162 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: sonarrnamingconfigs.devopsarr.io +spec: + group: devopsarr.io + names: + categories: [] + kind: SonarrNamingConfig + plural: sonarrnamingconfigs + shortNames: + - snc + singular: sonarrnamingconfig + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.sonarrInstanceRef.name + name: Instance + type: string + - jsonPath: .spec.renameEpisodes + name: Rename + type: boolean + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Auto-generated derived type for SonarrNamingConfigSpec via `CustomResource` + properties: + spec: + description: |- + SonarrNamingConfig configures episode naming settings for a Sonarr instance. + Only one SonarrNamingConfig per Sonarr instance is allowed. + properties: + animeEpisodeFormat: + description: |- + Anime episode format + Example: "{Series Title} - S{season:00}E{episode:00} - {Episode Title} {Quality Full}" + nullable: true + type: string + colonReplacementFormat: + description: Colon replacement format (0=Delete, 1=Dash, 2=SpaceDash, 3=SpaceDashSpace, 4=Smart) + format: int32 + nullable: true + type: integer + customColonReplacementFormat: + description: Custom colon replacement format string + nullable: true + type: string + dailyEpisodeFormat: + description: |- + Daily episode format + Example: "{Series Title} - {Air-Date} - {Episode Title} {Quality Full}" + nullable: true + type: string + multiEpisodeStyle: + description: Multi-episode style (0=Extend, 1=Duplicate, 2=Repeat, 3=Scene, 4=Range, 5=PrefixedRange) + format: int32 + nullable: true + type: integer + renameEpisodes: + description: Enable episode renaming + nullable: true + type: boolean + replaceIllegalCharacters: + description: Replace illegal characters in filenames + nullable: true + type: boolean + seasonFolderFormat: + description: |- + Season folder format + Example: "Season {season}" + nullable: true + type: string + seriesFolderFormat: + description: |- + Series folder format + Example: "{Series Title}" + nullable: true + type: string + sonarrInstanceRef: + description: Reference to the Sonarr instance + properties: + name: + default: '' + description: Name of the SonarrInstance resource + type: string + namespace: + description: Namespace of the SonarrInstance (optional, defaults to same namespace) + nullable: true + type: string + type: object + specialsFolderFormat: + description: |- + Specials folder format + Example: "Specials" + nullable: true + type: string + standardEpisodeFormat: + description: |- + Standard episode format + Example: "{Series Title} - S{season:00}E{episode:00} - {Episode Title} {Quality Full}" + nullable: true + type: string + required: + - sonarrInstanceRef + type: object + status: + nullable: true + properties: + conditions: + default: [] + description: Current conditions + items: + description: Condition contains details for one aspect of the current state of this API Resource. + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating details about the transition. This may be an empty string. + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance. + format: int64 + type: integer + reason: + description: reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty. + type: string + status: + description: status of the condition, one of True, False, Unknown. + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + observedGeneration: + default: 0 + description: Observed generation + format: int64 + type: integer + type: object + required: + - spec + title: SonarrNamingConfig + type: object + served: true + storage: true + subresources: + status: {} diff --git a/deploy/crds/notification.yaml b/deploy/crds/notification.yaml new file mode 100644 index 0000000..11140c3 --- /dev/null +++ b/deploy/crds/notification.yaml @@ -0,0 +1,462 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: sonarrnotifications.devopsarr.io +spec: + group: devopsarr.io + names: + categories: [] + kind: SonarrNotification + plural: sonarrnotifications + shortNames: + - snot + singular: sonarrnotification + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.name + name: Name + type: string + - jsonPath: .spec.notificationType + name: Type + type: string + - jsonPath: .status.id + name: ID + type: integer + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Auto-generated derived type for SonarrNotificationSpec via `CustomResource` + properties: + spec: + description: |- + SonarrNotification represents a notification/connect configuration in Sonarr + Notifications are used to alert on events (Discord, Telegram, Webhook, etc.) + properties: + config: + description: Notification configuration + properties: + apiKeySecretRef: + description: API key secret reference + nullable: true + properties: + key: + description: Key in the secret + type: string + name: + description: Name of the secret + type: string + required: + - key + - name + type: object + appTokenSecretRef: + description: Gotify app token secret reference + nullable: true + properties: + key: + description: Key in the secret + type: string + name: + description: Name of the secret + type: string + required: + - key + - name + type: object + arguments: + description: Script arguments + nullable: true + type: string + authTokenSecretRef: + description: Auth token secret reference + nullable: true + properties: + key: + description: Key in the secret + type: string + name: + description: Name of the secret + type: string + required: + - key + - name + type: object + avatar: + description: Discord avatar + nullable: true + type: string + bcc: + default: [] + description: BCC addresses + items: + type: string + type: array + botTokenSecretRef: + description: Telegram bot token secret reference + nullable: true + properties: + key: + description: Key in the secret + type: string + name: + description: Name of the secret + type: string + required: + - key + - name + type: object + cc: + default: [] + description: CC addresses + items: + type: string + type: array + channel: + description: Slack channel + nullable: true + type: string + chatId: + description: Telegram chat ID + nullable: true + type: string + clickUrl: + description: Click URL + nullable: true + type: string + devices: + default: [] + description: Device list + items: + type: string + type: array + discordUsername: + description: Discord username + nullable: true + type: string + expire: + description: Expire after (seconds) + format: int32 + nullable: true + type: integer + from: + description: From address + nullable: true + type: string + host: + description: Server host + nullable: true + type: string + icon: + description: Slack icon + nullable: true + type: string + mapTo: + description: Notify on specific library sections + nullable: true + type: string + method: + description: HTTP Method (1 = POST, 2 = PUT) + format: int32 + nullable: true + type: integer + ntfyTags: + default: [] + description: Ntfy tags + items: + type: string + type: array + passwordSecretRef: + description: Password secret reference + nullable: true + properties: + key: + description: Key in the secret + type: string + name: + description: Name of the secret + type: string + required: + - key + - name + type: object + path: + description: Path to script + nullable: true + type: string + port: + description: SMTP port + format: int32 + nullable: true + type: integer + priority: + description: Priority level + format: int32 + nullable: true + type: integer + requireEncryption: + default: false + description: Require encryption + type: boolean + retry: + description: Retry interval (seconds) + format: int32 + nullable: true + type: integer + sendSilently: + default: false + description: Send silently + type: boolean + server: + description: SMTP server + nullable: true + type: string + serverUrl: + description: Ntfy server URL + nullable: true + type: string + slackWebhookUrl: + description: Slack webhook URL + nullable: true + type: string + sound: + description: Sound + nullable: true + type: string + to: + default: [] + description: To addresses + items: + type: string + type: array + topic: + description: Ntfy topic + nullable: true + type: string + updateLibrary: + default: false + description: Update library + type: boolean + url: + description: Webhook URL + nullable: true + type: string + useSsl: + default: false + description: Use SSL + type: boolean + userKeySecretRef: + description: User key secret reference + nullable: true + properties: + key: + description: Key in the secret + type: string + name: + description: Name of the secret + type: string + required: + - key + - name + type: object + username: + description: Username for basic auth + nullable: true + type: string + webhookUrl: + description: Discord webhook URL + nullable: true + type: string + type: object + name: + description: Notification name + type: string + notificationType: + description: Notification type + enum: + - Apprise + - CustomScript + - Discord + - Email + - Emby + - Gotify + - Join + - Kodi + - Mailgun + - Ntfy + - Plex + - Prowl + - Pushbullet + - Pushover + - SendGrid + - Signal + - Simplepush + - Slack + - SynologyIndexer + - Telegram + - Trakt + - Twitter + - Webhook + type: string + sonarrInstanceRef: + description: Reference to the SonarrInstance + properties: + name: + default: '' + description: Name of the SonarrInstance resource + type: string + namespace: + description: Namespace of the SonarrInstance (optional, defaults to same namespace) + nullable: true + type: string + type: object + tags: + default: [] + description: Tags for this notification + items: + format: int32 + type: integer + type: array + triggers: + default: + includeHealthWarnings: false + onApplicationUpdate: false + onDownload: false + onEpisodeFileDelete: false + onEpisodeFileDeleteForUpgrade: false + onGrab: false + onHealthIssue: false + onHealthRestored: false + onImportComplete: false + onManualInteractionRequired: false + onRename: false + onSeriesAdd: false + onSeriesDelete: false + onUpgrade: false + description: Event triggers + properties: + includeHealthWarnings: + default: false + description: Include health warnings + type: boolean + onApplicationUpdate: + default: false + description: On application update + type: boolean + onDownload: + default: false + description: On download (episode is downloaded) + type: boolean + onEpisodeFileDelete: + default: false + description: On episode file delete + type: boolean + onEpisodeFileDeleteForUpgrade: + default: false + description: On episode file delete for upgrade + type: boolean + onGrab: + default: false + description: On grab (episode is grabbed) + type: boolean + onHealthIssue: + default: false + description: On health issue + type: boolean + onHealthRestored: + default: false + description: On health restored + type: boolean + onImportComplete: + default: false + description: On import complete + type: boolean + onManualInteractionRequired: + default: false + description: On manual interaction required + type: boolean + onRename: + default: false + description: On rename + type: boolean + onSeriesAdd: + default: false + description: On series add + type: boolean + onSeriesDelete: + default: false + description: On series delete + type: boolean + onUpgrade: + default: false + description: On upgrade (episode is upgraded) + type: boolean + type: object + required: + - config + - name + - notificationType + - sonarrInstanceRef + type: object + status: + nullable: true + properties: + conditions: + default: [] + description: Current conditions + items: + description: Condition contains details for one aspect of the current state of this API Resource. + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating details about the transition. This may be an empty string. + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance. + format: int64 + type: integer + reason: + description: reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty. + type: string + status: + description: status of the condition, one of True, False, Unknown. + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + id: + description: Sonarr Notification ID + format: int32 + nullable: true + type: integer + observedGeneration: + default: 0 + description: Observed generation + format: int64 + type: integer + type: object + required: + - spec + title: SonarrNotification + type: object + served: true + storage: true + subresources: + status: {} diff --git a/deploy/crds/qualitydefinition.yaml b/deploy/crds/qualitydefinition.yaml new file mode 100644 index 0000000..2533607 --- /dev/null +++ b/deploy/crds/qualitydefinition.yaml @@ -0,0 +1,163 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: sonarrqualitydefinitions.devopsarr.io +spec: + group: devopsarr.io + names: + categories: [] + kind: SonarrQualityDefinition + plural: sonarrqualitydefinitions + shortNames: + - sqd + singular: sonarrqualitydefinition + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.qualityName + name: Quality + type: string + - jsonPath: .spec.title + name: Title + type: string + - jsonPath: .spec.minSize + name: MinSize + type: number + - jsonPath: .spec.maxSize + name: MaxSize + type: number + - jsonPath: .status.id + name: ID + type: integer + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Auto-generated derived type for SonarrQualityDefinitionSpec via `CustomResource` + properties: + spec: + description: |- + SonarrQualityDefinition represents a quality definition configuration in Sonarr + Quality definitions control the size limits for each quality level + properties: + maxSize: + description: Maximum size in MB per minute of runtime (None = unlimited) + format: double + nullable: true + type: number + minSize: + description: Minimum size in MB per minute of runtime + format: double + nullable: true + type: number + preferredSize: + description: Preferred size in MB per minute of runtime + format: double + nullable: true + type: number + qualityName: + description: Quality name (must match existing quality in Sonarr) + enum: + - UNKNOWN + - SDTV + - DVD + - WEBDL-480p + - WEBRip-480p + - Bluray-480p + - HDTV-720p + - HDTV-1080p + - Raw-HD + - WEBDL-720p + - WEBRip-720p + - Bluray-720p + - WEBDL-1080p + - WEBRip-1080p + - Bluray-1080p + - Bluray-1080p Remux + - HDTV-2160p + - WEBDL-2160p + - WEBRip-2160p + - Bluray-2160p + - Bluray-2160p Remux + type: string + sonarrInstanceRef: + description: Reference to the SonarrInstance + properties: + name: + default: '' + description: Name of the SonarrInstance resource + type: string + namespace: + description: Namespace of the SonarrInstance (optional, defaults to same namespace) + nullable: true + type: string + type: object + title: + description: Title/display name for this quality + nullable: true + type: string + required: + - qualityName + - sonarrInstanceRef + type: object + status: + nullable: true + properties: + conditions: + default: [] + description: Current conditions + items: + description: Condition contains details for one aspect of the current state of this API Resource. + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating details about the transition. This may be an empty string. + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance. + format: int64 + type: integer + reason: + description: reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty. + type: string + status: + description: status of the condition, one of True, False, Unknown. + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + id: + description: Sonarr Quality Definition ID + format: int32 + nullable: true + type: integer + observedGeneration: + default: 0 + description: Observed generation + format: int64 + type: integer + type: object + required: + - spec + title: SonarrQualityDefinition + type: object + served: true + storage: true + subresources: + status: {} diff --git a/deploy/crds/qualityprofile.yaml b/deploy/crds/qualityprofile.yaml new file mode 100644 index 0000000..675bec5 --- /dev/null +++ b/deploy/crds/qualityprofile.yaml @@ -0,0 +1,203 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: sonarrqualityprofiles.devopsarr.io +spec: + group: devopsarr.io + names: + categories: [] + kind: SonarrQualityProfile + plural: sonarrqualityprofiles + shortNames: + - sqp + singular: sonarrqualityprofile + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.name + name: Name + type: string + - jsonPath: .spec.cutoff + name: Cutoff + type: integer + - jsonPath: .status.id + name: ID + type: integer + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Auto-generated derived type for SonarrQualityProfileSpec via `CustomResource` + properties: + spec: + description: |- + SonarrQualityProfile represents a quality profile in Sonarr + Quality profiles define which qualities are acceptable and their priority + properties: + cutoff: + default: 0 + description: Quality ID to use as cutoff + format: int32 + type: integer + cutoffFormatScore: + description: Cutoff format score + format: int32 + nullable: true + type: integer + formatItems: + default: [] + description: Format items (custom formats with scores) + items: + properties: + format: + description: Custom format ID + format: int32 + nullable: true + type: integer + name: + description: Format name + nullable: true + type: string + score: + default: 0 + description: Score for this format + format: int32 + type: integer + type: object + type: array + minFormatScore: + description: Minimum format score + format: int32 + nullable: true + type: integer + minUpgradeFormatScore: + description: Minimum upgrade format score + format: int32 + nullable: true + type: integer + name: + description: Quality profile name + type: string + qualityGroups: + description: Ordered list of quality groups + items: + properties: + id: + description: Quality group ID + format: int32 + nullable: true + type: integer + name: + description: Quality group name + nullable: true + type: string + qualities: + description: Ordered list of qualities in this group + items: + properties: + id: + description: Quality ID + format: int32 + nullable: true + type: integer + name: + description: Quality name + nullable: true + type: string + resolution: + description: Resolution + format: int32 + nullable: true + type: integer + source: + description: Source type + nullable: true + type: string + type: object + type: array + required: + - qualities + type: object + type: array + sonarrInstanceRef: + description: Reference to the SonarrInstance + properties: + name: + default: '' + description: Name of the SonarrInstance resource + type: string + namespace: + description: Namespace of the SonarrInstance (optional, defaults to same namespace) + nullable: true + type: string + type: object + upgradeAllowed: + default: false + description: Whether upgrades are allowed + type: boolean + required: + - name + - qualityGroups + - sonarrInstanceRef + type: object + status: + nullable: true + properties: + conditions: + default: [] + description: Current conditions + items: + description: Condition contains details for one aspect of the current state of this API Resource. + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating details about the transition. This may be an empty string. + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance. + format: int64 + type: integer + reason: + description: reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty. + type: string + status: + description: status of the condition, one of True, False, Unknown. + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + id: + description: Sonarr Quality Profile ID + format: int32 + nullable: true + type: integer + observedGeneration: + default: 0 + description: Observed generation + format: int64 + type: integer + type: object + required: + - spec + title: SonarrQualityProfile + type: object + served: true + storage: true + subresources: + status: {} diff --git a/deploy/crds/rootfolder.yaml b/deploy/crds/rootfolder.yaml new file mode 100644 index 0000000..06c1f18 --- /dev/null +++ b/deploy/crds/rootfolder.yaml @@ -0,0 +1,122 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: sonarrrootfolders.devopsarr.io +spec: + group: devopsarr.io + names: + categories: [] + kind: SonarrRootFolder + plural: sonarrrootfolders + shortNames: + - srf + singular: sonarrrootfolder + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.path + name: Path + type: string + - jsonPath: .status.id + name: ID + type: integer + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Auto-generated derived type for SonarrRootFolderSpec via `CustomResource` + properties: + spec: + description: |- + SonarrRootFolder represents a root folder in Sonarr + Root folders are the base directories where series are stored + properties: + path: + description: Root folder absolute path + type: string + sonarrInstanceRef: + description: Reference to the SonarrInstance + properties: + name: + default: '' + description: Name of the SonarrInstance resource + type: string + namespace: + description: Namespace of the SonarrInstance (optional, defaults to same namespace) + nullable: true + type: string + type: object + required: + - path + - sonarrInstanceRef + type: object + status: + nullable: true + properties: + accessible: + description: Whether the folder is accessible + nullable: true + type: boolean + conditions: + default: [] + description: Current conditions + items: + description: Condition contains details for one aspect of the current state of this API Resource. + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating details about the transition. This may be an empty string. + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance. + format: int64 + type: integer + reason: + description: reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty. + type: string + status: + description: status of the condition, one of True, False, Unknown. + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + freeSpace: + description: Free space in the folder + format: int64 + nullable: true + type: integer + id: + description: Sonarr Root Folder ID + format: int32 + nullable: true + type: integer + observedGeneration: + default: 0 + description: Observed generation + format: int64 + type: integer + type: object + required: + - spec + title: SonarrRootFolder + type: object + served: true + storage: true + subresources: + status: {} diff --git a/deploy/crds/series.yaml b/deploy/crds/series.yaml new file mode 100644 index 0000000..daebcb8 --- /dev/null +++ b/deploy/crds/series.yaml @@ -0,0 +1,238 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: sonarrseries.devopsarr.io +spec: + group: devopsarr.io + names: + categories: [] + kind: SonarrSeries + plural: sonarrseries + shortNames: + - ss + singular: sonarrseries + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.title + name: Title + type: string + - jsonPath: .spec.tvdbId + name: TVDB ID + type: integer + - jsonPath: .spec.monitored + name: Monitored + type: boolean + - jsonPath: .status.id + name: ID + type: integer + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Auto-generated derived type for SonarrSeriesSpec via `CustomResource` + properties: + spec: + description: |- + SonarrSeries represents a TV series managed in Sonarr + This allows declarative management of series in your library + properties: + addOptions: + default: + monitor: all + searchForCutoffUnmetEpisodes: false + searchForMissingEpisodes: true + description: Monitor type for adding series + properties: + monitor: + default: all + description: Monitor type + enum: + - all + - future + - missing + - existing + - recent + - pilot + - firstseason + - lastseason + - none + type: string + searchForCutoffUnmetEpisodes: + default: false + description: Search for cutoff unmet episodes + type: boolean + searchForMissingEpisodes: + default: true + description: Search for missing episodes when adding + type: boolean + type: object + monitored: + default: true + description: Whether the series is monitored + type: boolean + path: + description: Specific path override (optional) + nullable: true + type: string + qualityProfile: + description: Quality profile ID or name reference + properties: + id: + description: Quality profile ID + format: int32 + nullable: true + type: integer + name: + description: Quality profile name (will be resolved to ID) + nullable: true + type: string + type: object + rootFolderPath: + description: Root folder path for the series + type: string + seasonFolder: + default: true + description: Use season folders + type: boolean + seriesType: + default: standard + description: Series type + enum: + - standard + - daily + - anime + type: string + sonarrInstanceRef: + description: Reference to the SonarrInstance + properties: + name: + default: '' + description: Name of the SonarrInstance resource + type: string + namespace: + description: Namespace of the SonarrInstance (optional, defaults to same namespace) + nullable: true + type: string + type: object + tags: + default: [] + description: Tags for this series + items: + format: int32 + type: integer + type: array + title: + description: Series title + type: string + titleSlug: + description: Title slug (kebab-case version of title) + type: string + tvdbId: + description: TVDB ID for the series + format: int32 + type: integer + useSceneNumbering: + default: false + description: Use scene numbering + type: boolean + required: + - qualityProfile + - rootFolderPath + - sonarrInstanceRef + - title + - titleSlug + - tvdbId + type: object + status: + nullable: true + properties: + conditions: + default: [] + description: Current conditions + items: + description: Condition contains details for one aspect of the current state of this API Resource. + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating details about the transition. This may be an empty string. + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance. + format: int64 + type: integer + reason: + description: reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty. + type: string + status: + description: status of the condition, one of True, False, Unknown. + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + episodeCount: + description: Total episode count + format: int32 + nullable: true + type: integer + episodeFileCount: + description: Episode file count + format: int32 + nullable: true + type: integer + id: + description: Sonarr Series ID + format: int32 + nullable: true + type: integer + network: + description: Network + nullable: true + type: string + nextAiring: + description: Next airing date + nullable: true + type: string + observedGeneration: + default: 0 + description: Observed generation + format: int64 + type: integer + percentComplete: + description: Percentage complete + format: double + nullable: true + type: number + previousAiring: + description: Previous airing date + nullable: true + type: string + seriesStatus: + description: Status (continuing, ended, etc.) + nullable: true + type: string + type: object + required: + - spec + title: SonarrSeries + type: object + served: true + storage: true + subresources: + status: {} diff --git a/deploy/crds/sonarr.yaml b/deploy/crds/sonarr.yaml new file mode 100644 index 0000000..e876d37 --- /dev/null +++ b/deploy/crds/sonarr.yaml @@ -0,0 +1,640 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: sonarrs.devopsarr.io +spec: + group: devopsarr.io + names: + categories: [] + kind: Sonarr + plural: sonarrs + shortNames: + - snr + singular: sonarr + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .status.url + name: URL + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Auto-generated derived type for SonarrSpec via `CustomResource` + properties: + spec: + description: |- + Sonarr is the main CRD that deploys and manages a Sonarr instance + + This CRD creates: + - A Deployment with the Sonarr container + - An init container for database migrations + - A Service to expose Sonarr + - A PersistentVolumeClaim for configuration storage + - Optional Ingress for external access + properties: + apiKeySecretRef: + description: API key secret reference (optional - will be auto-generated if not provided) + nullable: true + properties: + key: + description: Key in the secret + type: string + name: + description: Name of the secret + type: string + required: + - key + - name + type: object + config: + default: + analyticsEnabled: null + authenticationMethod: null + authenticationRequired: null + bindAddress: null + initContainerImage: null + instanceName: null + logLevel: null + urlBase: null + description: Sonarr application configuration (config.xml settings) + properties: + analyticsEnabled: + description: 'Analytics enabled (default: true)' + nullable: true + type: boolean + authenticationMethod: + description: 'Authentication method: None, Basic, Forms, External (default: None)' + nullable: true + type: string + authenticationRequired: + description: 'Authentication required for API access (default: false)' + nullable: true + type: boolean + bindAddress: + description: 'Bind address (default: "*")' + nullable: true + type: string + initContainerImage: + description: 'Init container image used to configure config.xml (default: busybox:latest)' + nullable: true + type: string + instanceName: + description: Instance name displayed in the UI + nullable: true + type: string + logLevel: + description: 'Log level: trace, debug, info, warn, error (default: info)' + nullable: true + type: string + urlBase: + description: URL base for reverse proxy setups (e.g., "/sonarr") + nullable: true + type: string + type: object + env: + default: [] + description: Environment variables + items: + properties: + name: + description: Name of the environment variable + type: string + value: + description: Value of the environment variable + nullable: true + type: string + valueFrom: + description: Reference to a secret or configmap + nullable: true + properties: + configMapKeyRef: + description: ConfigMap key reference + nullable: true + properties: + key: + description: Key in the configmap + type: string + name: + description: Name of the configmap + type: string + required: + - key + - name + type: object + secretKeyRef: + description: Secret key reference + nullable: true + properties: + key: + description: Key in the secret + type: string + name: + description: Name of the secret + type: string + required: + - key + - name + type: object + type: object + required: + - name + type: object + type: array + httpRoute: + description: HTTPRoute configuration for Gateway API (optional) + nullable: true + properties: + annotations: + additionalProperties: + type: string + default: {} + description: Additional annotations for the HTTPRoute + type: object + enabled: + default: false + description: 'Enable HTTPRoute creation (default: false)' + type: boolean + gatewayRef: + description: Gateway reference - the Gateway to attach to + properties: + name: + description: Name of the Gateway + type: string + namespace: + description: Namespace of the Gateway (optional, defaults to same namespace as HTTPRoute) + nullable: true + type: string + sectionName: + description: Section name within the Gateway (optional) + nullable: true + type: string + required: + - name + type: object + hostnames: + default: [] + description: Hostnames for the HTTPRoute + items: + type: string + type: array + labels: + additionalProperties: + type: string + default: {} + description: Additional labels for the HTTPRoute + type: object + path: + default: / + description: 'Path match for the route (default: /)' + type: string + pathType: + default: PathPrefix + description: 'Path match type: Exact, PathPrefix, or RegularExpression (default: PathPrefix)' + type: string + required: + - gatewayRef + type: object + image: + default: lscr.io/linuxserver/sonarr:latest + description: 'Sonarr image to use (default: lscr.io/linuxserver/sonarr:latest)' + type: string + imagePullPolicy: + default: IfNotPresent + description: 'Image pull policy (default: IfNotPresent)' + type: string + ingress: + description: Ingress configuration (optional) + nullable: true + properties: + annotations: + additionalProperties: + type: string + default: {} + description: Ingress annotations + type: object + enabled: + default: false + description: 'Enable ingress (default: false)' + type: boolean + host: + description: Hostname for the ingress + type: string + ingressClassName: + description: Ingress class name + nullable: true + type: string + path: + default: / + description: 'Path for the ingress (default: /)' + type: string + pathType: + default: Prefix + description: 'Path type (default: Prefix)' + type: string + tls: + description: TLS configuration + nullable: true + properties: + hosts: + default: [] + description: Hosts covered by the TLS certificate + items: + type: string + type: array + secretName: + description: Secret name containing TLS certificate + type: string + required: + - secretName + type: object + required: + - host + type: object + initContainer: + description: Init container configuration (for custom init logic) + nullable: true + properties: + args: + default: [] + description: Arguments for the command + items: + type: string + type: array + command: + default: [] + description: Command to run in init container + items: + type: string + type: array + env: + default: [] + description: Environment variables for init container + items: + properties: + name: + description: Name of the environment variable + type: string + value: + description: Value of the environment variable + nullable: true + type: string + valueFrom: + description: Reference to a secret or configmap + nullable: true + properties: + configMapKeyRef: + description: ConfigMap key reference + nullable: true + properties: + key: + description: Key in the configmap + type: string + name: + description: Name of the configmap + type: string + required: + - key + - name + type: object + secretKeyRef: + description: Secret key reference + nullable: true + properties: + key: + description: Key in the secret + type: string + name: + description: Name of the secret + type: string + required: + - key + - name + type: object + type: object + required: + - name + type: object + type: array + image: + default: busybox:latest + description: 'Image for init container (default: busybox:latest)' + type: string + type: object + nodeSelector: + additionalProperties: + type: string + default: {} + description: Node selector + type: object + replicas: + default: 1 + description: Number of replicas (should be 1 for Sonarr) + format: int32 + type: integer + resources: + description: Resource requirements + nullable: true + properties: + limits: + additionalProperties: + type: string + default: {} + description: Resource limits + type: object + requests: + additionalProperties: + type: string + default: {} + description: Resource requests + type: object + type: object + securityContext: + description: Pod security context + nullable: true + properties: + fsGroup: + format: int64 + nullable: true + type: integer + runAsGroup: + format: int64 + nullable: true + type: integer + runAsNonRoot: + nullable: true + type: boolean + runAsUser: + format: int64 + nullable: true + type: integer + type: object + service: + default: + annotations: {} + containerPort: 0 + nodePort: null + port: 0 + serviceType: '' + description: Service configuration + properties: + annotations: + additionalProperties: + type: string + default: {} + description: Service annotations + type: object + containerPort: + default: 8989 + description: 'Container port - the port Sonarr listens on inside the container (default: 8989)' + format: int32 + type: integer + nodePort: + description: Node port (only for NodePort type) + format: int32 + nullable: true + type: integer + port: + default: 8989 + description: 'Service port (default: 8989)' + format: int32 + type: integer + serviceType: + default: ClusterIP + description: 'Service type (default: ClusterIP)' + type: string + type: object + storage: + default: + accessModes: [] + existingClaim: null + size: '' + storageClass: null + description: Storage configuration + properties: + accessModes: + default: + - ReadWriteOnce + description: 'Access modes (default: ReadWriteOnce)' + items: + type: string + type: array + existingClaim: + description: Existing PVC to use (optional) + nullable: true + type: string + size: + default: 1Gi + description: 'Size of the config PVC (default: 1Gi)' + type: string + storageClass: + description: Storage class for the PVC + nullable: true + type: string + type: object + tolerations: + default: [] + description: Tolerations + items: + properties: + effect: + nullable: true + type: string + key: + nullable: true + type: string + operator: + nullable: true + type: string + tolerationSeconds: + format: int64 + nullable: true + type: integer + value: + nullable: true + type: string + type: object + type: array + volumeMounts: + default: [] + description: Volume mounts for media directories + items: + properties: + mountPath: + description: Mount path inside the container + type: string + name: + description: Name of the volume + type: string + readOnly: + default: false + description: Read only flag + type: boolean + subPath: + description: Sub path (optional) + nullable: true + type: string + required: + - mountPath + - name + type: object + type: array + volumes: + default: [] + description: Additional volumes + items: + properties: + configMap: + description: ConfigMap volume + nullable: true + properties: + items: + default: [] + items: + properties: + key: + type: string + path: + type: string + required: + - key + - path + type: object + type: array + name: + type: string + required: + - name + type: object + emptyDir: + description: Empty dir volume + nullable: true + properties: + medium: + nullable: true + type: string + sizeLimit: + nullable: true + type: string + type: object + hostPath: + description: HostPath volume + nullable: true + properties: + path: + type: string + type: + nullable: true + type: string + required: + - path + type: object + name: + description: Name of the volume + type: string + nfs: + description: NFS volume + nullable: true + properties: + path: + type: string + readOnly: + default: false + type: boolean + server: + type: string + required: + - path + - server + type: object + persistentVolumeClaim: + description: PVC claim + nullable: true + properties: + claimName: + type: string + readOnly: + default: false + type: boolean + required: + - claimName + type: object + required: + - name + type: object + type: array + type: object + status: + nullable: true + properties: + apiKeySecret: + description: API key (stored in secret) + nullable: true + type: string + conditions: + default: [] + description: Current conditions + items: + description: Condition contains details for one aspect of the current state of this API Resource. + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating details about the transition. This may be an empty string. + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance. + format: int64 + type: integer + reason: + description: reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty. + type: string + status: + description: status of the condition, one of True, False, Unknown. + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + observedGeneration: + default: 0 + description: Observed generation + format: int64 + type: integer + readyReplicas: + default: 0 + description: Number of ready replicas + format: int32 + type: integer + url: + description: URL to access Sonarr + nullable: true + type: string + version: + description: Sonarr version + nullable: true + type: string + type: object + required: + - spec + title: Sonarr + type: object + served: true + storage: true + subresources: + status: {} diff --git a/deploy/crds/tag.yaml b/deploy/crds/tag.yaml new file mode 100644 index 0000000..d3a98ae --- /dev/null +++ b/deploy/crds/tag.yaml @@ -0,0 +1,113 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: sonarrtags.devopsarr.io +spec: + group: devopsarr.io + names: + categories: [] + kind: SonarrTag + plural: sonarrtags + shortNames: + - stag + singular: sonarrtag + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.label + name: Label + type: string + - jsonPath: .status.id + name: ID + type: integer + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Auto-generated derived type for SonarrTagSpec via `CustomResource` + properties: + spec: + description: |- + SonarrTag represents a tag in Sonarr + Tags are used to organize and filter series, profiles, and other resources + properties: + label: + description: Tag label (must be lowercase) + type: string + sonarrInstanceRef: + description: Reference to the SonarrInstance + properties: + name: + default: '' + description: Name of the SonarrInstance resource + type: string + namespace: + description: Namespace of the SonarrInstance (optional, defaults to same namespace) + nullable: true + type: string + type: object + required: + - label + - sonarrInstanceRef + type: object + status: + nullable: true + properties: + conditions: + default: [] + description: Current conditions + items: + description: Condition contains details for one aspect of the current state of this API Resource. + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating details about the transition. This may be an empty string. + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance. + format: int64 + type: integer + reason: + description: reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty. + type: string + status: + description: status of the condition, one of True, False, Unknown. + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + id: + description: Sonarr Tag ID + format: int32 + nullable: true + type: integer + observedGeneration: + default: 0 + description: Observed generation + format: int64 + type: integer + type: object + required: + - spec + title: SonarrTag + type: object + served: true + storage: true + subresources: + status: {} diff --git a/deploy/deployment.yaml b/deploy/deployment.yaml new file mode 100644 index 0000000..dc8319c --- /dev/null +++ b/deploy/deployment.yaml @@ -0,0 +1,40 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: sonarr-operator + namespace: sonarr-operator-system + labels: + app.kubernetes.io/name: sonarr-operator + app.kubernetes.io/component: operator +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: sonarr-operator + template: + metadata: + labels: + app.kubernetes.io/name: sonarr-operator + spec: + serviceAccountName: sonarr-operator + containers: + - name: operator + image: k3d-registry:5000/sonarr-operator:latest + imagePullPolicy: Always + env: + - name: RUST_LOG + value: 'info,sonarr_operator=debug' + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 200m + memory: 256Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsUser: 65534 + securityContext: + fsGroup: 65534 diff --git a/deploy/namespace.yaml b/deploy/namespace.yaml new file mode 100644 index 0000000..463f28e --- /dev/null +++ b/deploy/namespace.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: sonarr-operator-system + labels: + app.kubernetes.io/name: sonarr-operator + app.kubernetes.io/managed-by: sonarr-operator diff --git a/deploy/rbac.yaml b/deploy/rbac.yaml new file mode 100644 index 0000000..a27e181 --- /dev/null +++ b/deploy/rbac.yaml @@ -0,0 +1,59 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: sonarr-operator + namespace: sonarr-operator-system +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: sonarr-operator +rules: + # Core API resources + - apiGroups: [''] + resources: ['secrets'] + verbs: ['get', 'list', 'watch', 'create', 'update', 'patch', 'delete'] + - apiGroups: [''] + resources: ['services'] + verbs: ['get', 'list', 'watch', 'create', 'update', 'patch', 'delete'] + - apiGroups: [''] + resources: ['persistentvolumeclaims'] + verbs: ['get', 'list', 'watch', 'create', 'update', 'patch', 'delete'] + - apiGroups: [''] + resources: ['events'] + verbs: ['create', 'patch'] + # Apps API + - apiGroups: ['apps'] + resources: ['deployments'] + verbs: ['get', 'list', 'watch', 'create', 'update', 'patch', 'delete'] + # Networking API (Ingress) + - apiGroups: ['networking.k8s.io'] + resources: ['ingresses'] + verbs: ['get', 'list', 'watch', 'create', 'update', 'patch', 'delete'] + # Gateway API (HTTPRoute) + - apiGroups: ['gateway.networking.k8s.io'] + resources: ['httproutes'] + verbs: ['get', 'list', 'watch', 'create', 'update', 'patch', 'delete'] + # Sonarr CRDs + - apiGroups: ['devopsarr.io'] + resources: ['*'] + verbs: ['*'] + - apiGroups: ['devopsarr.io'] + resources: ['*/status'] + verbs: ['get', 'patch', 'update'] + - apiGroups: ['devopsarr.io'] + resources: ['*/finalizers'] + verbs: ['update'] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: sonarr-operator +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: sonarr-operator +subjects: + - kind: ServiceAccount + name: sonarr-operator + namespace: sonarr-operator-system diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..2c18478 --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,282 @@ +# Testing the Sonarr Kubernetes Operator + +This guide covers different approaches to testing the operator, from unit tests to full end-to-end testing on Kubernetes. + +## Prerequisites + +- Rust toolchain (1.75+) +- Docker +- kubectl +- [Kind](https://kind.sigs.k8s.io/) (for local Kubernetes testing) +- A Sonarr instance (for integration tests) + +## Quick Start + +```bash +# Run linting and unit tests +make lint test + +# Build the operator +make build + +# Generate CRDs +make crds +``` + +--- + +## 1. Unit Testing + +Run unit tests with: + +```bash +make test +# or with verbose output +make test-verbose +``` + +Unit tests are located in each module and test individual functions without external dependencies. + +--- + +## 2. Local Development Testing + +### Run Operator Locally + +You can run the operator locally against any Kubernetes cluster: + +```bash +# Ensure you have a valid kubeconfig +export KUBECONFIG=~/.kube/config + +# Install CRDs first +make install + +# Run the operator with debug logging +make run-debug +``` + +The operator will connect to your cluster and start reconciling Sonarr resources. + +--- + +## 3. Integration Testing with Kind + +[Kind](https://kind.sigs.k8s.io/) provides a local Kubernetes cluster using Docker containers. + +### Setup Kind Cluster + +```bash +# Create a Kind cluster +make kind-create + +# Install CRDs +make install +``` + +### Deploy Test Sonarr Instance + +```bash +# Deploy a real Sonarr instance for testing +kubectl apply -f tests/e2e/fixtures/sonarr-deployment.yaml + +# Wait for Sonarr to be ready +kubectl wait --for=condition=available deployment/sonarr -n sonarr-test --timeout=120s + +# Port-forward to access Sonarr UI (optional) +kubectl port-forward -n sonarr-test svc/sonarr 8989:8989 +``` + +### Get Sonarr API Key + +```bash +# Wait for Sonarr to initialize (creates config.xml) +sleep 30 + +# Extract API key from Sonarr config +kubectl exec -n sonarr-test deployment/sonarr -- cat /config/config.xml | grep -oP '(?<=)[^<]+' +``` + +### Update API Key Secret + +Edit `tests/e2e/fixtures/01-sonarr-instance.yaml` and replace the placeholder API key with the actual value: + +```bash +# Or create the secret directly +kubectl create secret generic sonarr-api-key -n sonarr-test \ + --from-literal=api-key=YOUR_ACTUAL_API_KEY \ + --dry-run=client -o yaml | kubectl apply -f - +``` + +### Run Operator and Test Resources + +```bash +# In terminal 1: Run the operator +make run-debug + +# In terminal 2: Apply test resources +kubectl apply -f tests/e2e/fixtures/01-sonarr-instance.yaml +kubectl apply -f tests/e2e/fixtures/02-tags.yaml +kubectl apply -f tests/e2e/fixtures/03-root-folders.yaml + +# Check the resources +kubectl get sonarrs,sonarrtags,sonarrrootfolders -n sonarr-test -o wide + +# Check status of a specific resource +kubectl describe sonarrtag test-tag-anime -n sonarr-test +``` + +### Verify in Sonarr + +Access the Sonarr UI at http://localhost:8989 (if port-forwarded) and verify: +- Tags appear in Settings → Tags +- Root folders appear in Settings → Media Management + +### Cleanup + +```bash +make kind-delete +``` + +--- + +## 4. Full E2E Testing with Docker Image + +Build and deploy the operator as a container: + +```bash +# Create Kind cluster +make kind-create + +# Build Docker image +make docker + +# Load image into Kind +make kind-load + +# Deploy operator to cluster +make deploy + +# Apply test fixtures +kubectl apply -f tests/e2e/fixtures/ + +# Watch resources +kubectl get sonarrs,sonarrtags -n sonarr-test -w +``` + +--- + +## 5. Testing Individual Components + +### Test CRD Generation + +```bash +# Generate all CRDs +make crds +cat deploy/crds/crds.yaml + +# Generate single CRD +make crds-single CRD=SonarrTag +``` + +### Test Specific Resource Types + +```bash +# Apply and test tags +kubectl apply -f - <[↩ Parent](#devopsarriov1alpha1 ) + + + + + + +Auto-generated derived type for SonarrAutoTagSpec via `CustomResource` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
apiVersionstringdevopsarr.io/v1alpha1true
kindstringSonarrAutoTagtrue
metadataobjectRefer to the Kubernetes API documentation for the fields of the `metadata` field.true
specobject + SonarrAutoTag represents an auto-tagging rule configuration in Sonarr +Auto-tagging automatically applies tags to series based on conditions
+
true
statusobject +
+
false
+ + +### SonarrAutoTag.spec +[↩ Parent](#sonarrautotag) + + + +SonarrAutoTag represents an auto-tagging rule configuration in Sonarr +Auto-tagging automatically applies tags to series based on conditions + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring + Auto-tag rule name
+
true
sonarrInstanceRefobject + Reference to the SonarrInstance
+
true
removeTagsAutomaticallyboolean + Remove tags automatically when conditions no longer match
+
+ Default: false
+
false
specifications[]object + Specifications (conditions) for this auto-tag rule
+
+ Default: []
+
false
tags[]integer + Tags to apply when conditions match
+
+ Default: []
+
false
+ + +### SonarrAutoTag.spec.sonarrInstanceRef +[↩ Parent](#sonarrautotagspec) + + + +Reference to the SonarrInstance + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring + Name of the SonarrInstance resource
+
+ Default:
+
false
namespacestring + Namespace of the SonarrInstance (optional, defaults to same namespace)
+
false
+ + +### SonarrAutoTag.spec.specifications[index] +[↩ Parent](#sonarrautotagspec) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
implementationenum + Specification type/implementation
+
+ Enum: rootFolderSpecification, genreSpecification, yearSpecification, seriesTypeSpecification, qualityProfileSpecification, networkSpecification, originalLanguageSpecification, tagSpecification
+
true
namestring + Specification name
+
true
fieldsobject + Fields/values for this specification
+
+ Default: map[max: min: value:]
+
false
negateboolean + Negate this condition
+
+ Default: false
+
false
requiredboolean + This condition is required
+
+ Default: true
+
false
+ + +### SonarrAutoTag.spec.specifications[index].fields +[↩ Parent](#sonarrautotagspecspecificationsindex) + + + +Fields/values for this specification + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
maxinteger + Maximum value (for year specifications)
+
+ Format: int32
+
false
mininteger + Minimum value (for year specifications)
+
+ Format: int32
+
false
valuestring + Value for the specification (path, genre, network, etc.)
+
false
+ + +### SonarrAutoTag.status +[↩ Parent](#sonarrautotag) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
conditions[]object + Current conditions
+
+ Default: []
+
false
idinteger + Sonarr Auto Tag ID
+
+ Format: int32
+
false
observedGenerationinteger + Observed generation
+
+ Format: int64
+ Default: 0
+
false
+ + +### SonarrAutoTag.status.conditions[index] +[↩ Parent](#sonarrautotagstatus) + + + +Condition contains details for one aspect of the current state of this API Resource. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
lastTransitionTimestring + lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+
+ Format: date-time
+
true
messagestring + message is a human readable message indicating details about the transition. This may be an empty string.
+
true
reasonstring + reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty.
+
true
statusstring + status of the condition, one of True, False, Unknown.
+
true
typestring + type of condition in CamelCase or in foo.example.com/CamelCase.
+
true
observedGenerationinteger + observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance.
+
+ Format: int64
+
false
+ +## SonarrCustomFormat +[↩ Parent](#devopsarriov1alpha1 ) + + + + + + +Auto-generated derived type for SonarrCustomFormatSpec via `CustomResource` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
apiVersionstringdevopsarr.io/v1alpha1true
kindstringSonarrCustomFormattrue
metadataobjectRefer to the Kubernetes API documentation for the fields of the `metadata` field.true
specobject + SonarrCustomFormat represents a custom format configuration in Sonarr +Custom formats are used to score releases based on various criteria
+
true
statusobject +
+
false
+ + +### SonarrCustomFormat.spec +[↩ Parent](#sonarrcustomformat) + + + +SonarrCustomFormat represents a custom format configuration in Sonarr +Custom formats are used to score releases based on various criteria + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring + Custom format name
+
true
sonarrInstanceRefobject + Reference to the SonarrInstance
+
true
includeCustomFormatWhenRenamingboolean + Include custom format name when renaming files
+
+ Default: false
+
false
specifications[]object + Specifications (conditions) for this custom format
+
+ Default: []
+
false
+ + +### SonarrCustomFormat.spec.sonarrInstanceRef +[↩ Parent](#sonarrcustomformatspec) + + + +Reference to the SonarrInstance + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring + Name of the SonarrInstance resource
+
+ Default:
+
false
namespacestring + Namespace of the SonarrInstance (optional, defaults to same namespace)
+
false
+ + +### SonarrCustomFormat.spec.specifications[index] +[↩ Parent](#sonarrcustomformatspec) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
implementationenum + Specification type/implementation
+
+ Enum: releaseTitleSpecification, sourceSpecification, resolutionSpecification, qualityModifierSpecification, sizeSpecification, indexerFlagSpecification, languageSpecification, releaseGroupSpecification, editionSpecification
+
true
namestring + Specification name
+
true
fieldsobject + Fields/values for this specification
+
+ Default: map[max: min: value:]
+
false
negateboolean + Negate this condition
+
+ Default: false
+
false
requiredboolean + This condition is required
+
+ Default: true
+
false
+ + +### SonarrCustomFormat.spec.specifications[index].fields +[↩ Parent](#sonarrcustomformatspecspecificationsindex) + + + +Fields/values for this specification + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
maxnumber + Maximum value (for size specifications)
+
+ Format: double
+
false
minnumber + Minimum value (for size specifications)
+
+ Format: double
+
false
valuestring + Value for the specification (regex pattern, source type, etc.)
+
false
+ + +### SonarrCustomFormat.status +[↩ Parent](#sonarrcustomformat) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
conditions[]object + Current conditions
+
+ Default: []
+
false
idinteger + Sonarr Custom Format ID
+
+ Format: int32
+
false
observedGenerationinteger + Observed generation
+
+ Format: int64
+ Default: 0
+
false
+ + +### SonarrCustomFormat.status.conditions[index] +[↩ Parent](#sonarrcustomformatstatus) + + + +Condition contains details for one aspect of the current state of this API Resource. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
lastTransitionTimestring + lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+
+ Format: date-time
+
true
messagestring + message is a human readable message indicating details about the transition. This may be an empty string.
+
true
reasonstring + reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty.
+
true
statusstring + status of the condition, one of True, False, Unknown.
+
true
typestring + type of condition in CamelCase or in foo.example.com/CamelCase.
+
true
observedGenerationinteger + observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance.
+
+ Format: int64
+
false
+ +## SonarrDelayProfile +[↩ Parent](#devopsarriov1alpha1 ) + + + + + + +Auto-generated derived type for SonarrDelayProfileSpec via `CustomResource` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
apiVersionstringdevopsarr.io/v1alpha1true
kindstringSonarrDelayProfiletrue
metadataobjectRefer to the Kubernetes API documentation for the fields of the `metadata` field.true
specobject + SonarrDelayProfile represents a delay profile configuration in Sonarr +Delay profiles control how long Sonarr waits before grabbing a release
+
true
statusobject +
+
false
+ + +### SonarrDelayProfile.spec +[↩ Parent](#sonarrdelayprofile) + + + +SonarrDelayProfile represents a delay profile configuration in Sonarr +Delay profiles control how long Sonarr waits before grabbing a release + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
sonarrInstanceRefobject + Reference to the SonarrInstance
+
true
bypassIfAboveCustomFormatScoreboolean + Bypass delay if above custom format score
+
+ Default: false
+
false
bypassIfHighestQualityboolean + Bypass delay if highest quality
+
+ Default: false
+
false
enableTorrentboolean + Enable Torrent downloads
+
+ Default: true
+
false
enableUsenetboolean + Enable Usenet downloads
+
+ Default: true
+
false
minimumCustomFormatScoreinteger + Minimum custom format score to bypass delay
+
+ Format: int32
+ Default: 0
+
false
orderinteger + Order of this profile (lower = higher priority)
+
+ Format: int32
+ Default: 0
+
false
preferredProtocolenum + Preferred download protocol
+
+ Enum: usenet, torrent
+ Default: usenet
+
false
tags[]integer + Tags to apply this delay profile to
+
+ Default: []
+
false
torrentDelayinteger + Delay for Torrents in minutes
+
+ Format: int32
+ Default: 0
+
false
usenetDelayinteger + Delay for Usenet in minutes
+
+ Format: int32
+ Default: 0
+
false
+ + +### SonarrDelayProfile.spec.sonarrInstanceRef +[↩ Parent](#sonarrdelayprofilespec) + + + +Reference to the SonarrInstance + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring + Name of the SonarrInstance resource
+
+ Default:
+
false
namespacestring + Namespace of the SonarrInstance (optional, defaults to same namespace)
+
false
+ + +### SonarrDelayProfile.status +[↩ Parent](#sonarrdelayprofile) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
conditions[]object + Current conditions
+
+ Default: []
+
false
idinteger + Sonarr Delay Profile ID
+
+ Format: int32
+
false
observedGenerationinteger + Observed generation
+
+ Format: int64
+ Default: 0
+
false
+ + +### SonarrDelayProfile.status.conditions[index] +[↩ Parent](#sonarrdelayprofilestatus) + + + +Condition contains details for one aspect of the current state of this API Resource. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
lastTransitionTimestring + lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+
+ Format: date-time
+
true
messagestring + message is a human readable message indicating details about the transition. This may be an empty string.
+
true
reasonstring + reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty.
+
true
statusstring + status of the condition, one of True, False, Unknown.
+
true
typestring + type of condition in CamelCase or in foo.example.com/CamelCase.
+
true
observedGenerationinteger + observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance.
+
+ Format: int64
+
false
+ +## SonarrDownloadClient +[↩ Parent](#devopsarriov1alpha1 ) + + + + + + +Auto-generated derived type for SonarrDownloadClientSpec via `CustomResource` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
apiVersionstringdevopsarr.io/v1alpha1true
kindstringSonarrDownloadClienttrue
metadataobjectRefer to the Kubernetes API documentation for the fields of the `metadata` field.true
specobject + SonarrDownloadClient represents a download client configuration in Sonarr +Download clients are used to download releases (qBittorrent, Transmission, SABnzbd, etc.)
+
true
statusobject +
+
false
+ + +### SonarrDownloadClient.spec +[↩ Parent](#sonarrdownloadclient) + + + +SonarrDownloadClient represents a download client configuration in Sonarr +Download clients are used to download releases (qBittorrent, Transmission, SABnzbd, etc.) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
configobject + Download client configuration
+
true
downloadClientTypeenum + Download client type
+
+ Enum: Aria2, Deluge, Flood, Hadouken, Nzbget, Nzbvortex, Pneumatic, QBittorrent, RTorrent, Sabnzbd, TorrentBlackhole, TorrentDownloadStation, Transmission, UsenetBlackhole, UsenetDownloadStation, UTorrent, Vuze
+
true
namestring + Download client name
+
true
sonarrInstanceRefobject + Reference to the SonarrInstance
+
true
enableboolean + Enable this download client
+
+ Default: true
+
false
priorityinteger + Priority for this download client
+
+ Format: int32
+ Default: 1
+
false
removeCompletedDownloadsboolean + Remove completed downloads
+
+ Default: true
+
false
removeFailedDownloadsboolean + Remove failed downloads
+
+ Default: true
+
false
tags[]integer + Tags for this download client
+
+ Default: []
+
false
+ + +### SonarrDownloadClient.spec.config +[↩ Parent](#sonarrdownloadclientspec) + + + +Download client configuration + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
addPausedboolean + Add paused
+
+ Default: false
+
false
apiKeySecretRefobject + API key from secret (for some clients)
+
false
firstAndLastboolean + First and last (for qBittorrent)
+
+ Default: false
+
false
hoststring + Host address
+
false
initialStateinteger + Initial state (for qBittorrent: 0 = Start, 1 = ForceStart, 2 = Pause)
+
+ Format: int32
+
false
nzbFolderstring + NZB folder (for blackhole)
+
false
olderTvPriorityinteger + Older TV priority (0 = Last, 1 = First)
+
+ Format: int32
+
false
passwordSecretRefobject + Password from secret
+
false
portinteger + Port number
+
+ Format: int32
+
false
recentTvPriorityinteger + Recent TV priority (0 = Last, 1 = First)
+
+ Format: int32
+
false
rpcPathstring + RPC path (for Aria2)
+
false
saveMagnetFilesboolean + Save magnet files (for blackhole)
+
+ Default: false
+
false
secretTokenSecretRefobject + Secret token (for Aria2)
+
false
sequentialOrderboolean + Sequential order (for qBittorrent)
+
+ Default: false
+
false
strmFolderstring + Strm folder (for pneumatic)
+
false
torrentFolderstring + Torrent folder (for blackhole)
+
false
tvCategorystring + TV category
+
false
tvDirectorystring + TV directory
+
false
urlBasestring + URL base path
+
false
useSslboolean + Use SSL
+
+ Default: false
+
false
usernamestring + Username
+
false
watchFolderstring + Watch folder (for blackhole)
+
false
+ + +### SonarrDownloadClient.spec.config.apiKeySecretRef +[↩ Parent](#sonarrdownloadclientspecconfig) + + + +API key from secret (for some clients) + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
keystring + Key in the secret
+
true
namestring + Name of the secret
+
true
+ + +### SonarrDownloadClient.spec.config.passwordSecretRef +[↩ Parent](#sonarrdownloadclientspecconfig) + + + +Password from secret + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
keystring + Key in the secret
+
true
namestring + Name of the secret
+
true
+ + +### SonarrDownloadClient.spec.config.secretTokenSecretRef +[↩ Parent](#sonarrdownloadclientspecconfig) + + + +Secret token (for Aria2) + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
keystring + Key in the secret
+
true
namestring + Name of the secret
+
true
+ + +### SonarrDownloadClient.spec.sonarrInstanceRef +[↩ Parent](#sonarrdownloadclientspec) + + + +Reference to the SonarrInstance + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring + Name of the SonarrInstance resource
+
+ Default:
+
false
namespacestring + Namespace of the SonarrInstance (optional, defaults to same namespace)
+
false
+ + +### SonarrDownloadClient.status +[↩ Parent](#sonarrdownloadclient) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
conditions[]object + Current conditions
+
+ Default: []
+
false
idinteger + Sonarr Download Client ID
+
+ Format: int32
+
false
observedGenerationinteger + Observed generation
+
+ Format: int64
+ Default: 0
+
false
+ + +### SonarrDownloadClient.status.conditions[index] +[↩ Parent](#sonarrdownloadclientstatus) + + + +Condition contains details for one aspect of the current state of this API Resource. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
lastTransitionTimestring + lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+
+ Format: date-time
+
true
messagestring + message is a human readable message indicating details about the transition. This may be an empty string.
+
true
reasonstring + reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty.
+
true
statusstring + status of the condition, one of True, False, Unknown.
+
true
typestring + type of condition in CamelCase or in foo.example.com/CamelCase.
+
true
observedGenerationinteger + observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance.
+
+ Format: int64
+
false
+ +## SonarrDownloadClientConfig +[↩ Parent](#devopsarriov1alpha1 ) + + + + + + +Auto-generated derived type for SonarrDownloadClientConfigSpec via `CustomResource` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
apiVersionstringdevopsarr.io/v1alpha1true
kindstringSonarrDownloadClientConfigtrue
metadataobjectRefer to the Kubernetes API documentation for the fields of the `metadata` field.true
specobject + SonarrDownloadClientConfig configures global download client settings for a Sonarr instance. +Only one SonarrDownloadClientConfig per Sonarr instance is allowed. +Note: This is different from SonarrDownloadClient which configures individual download clients.
+
true
statusobject +
+
false
+ + +### SonarrDownloadClientConfig.spec +[↩ Parent](#sonarrdownloadclientconfig) + + + +SonarrDownloadClientConfig configures global download client settings for a Sonarr instance. +Only one SonarrDownloadClientConfig per Sonarr instance is allowed. +Note: This is different from SonarrDownloadClient which configures individual download clients. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
sonarrInstanceRefobject + Reference to the Sonarr instance
+
true
autoRedownloadFailedboolean + Automatically redownload failed releases
+
false
autoRedownloadFailedFromInteractiveSearchboolean + Automatically redownload failed releases from interactive search
+
false
downloadClientWorkingFoldersstring + Working folders for download client (container path mapping)
+
false
enableCompletedDownloadHandlingboolean + Enable completed download handling
+
false
+ + +### SonarrDownloadClientConfig.spec.sonarrInstanceRef +[↩ Parent](#sonarrdownloadclientconfigspec) + + + +Reference to the Sonarr instance + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring + Name of the SonarrInstance resource
+
+ Default:
+
false
namespacestring + Namespace of the SonarrInstance (optional, defaults to same namespace)
+
false
+ + +### SonarrDownloadClientConfig.status +[↩ Parent](#sonarrdownloadclientconfig) + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
conditions[]object + Current conditions
+
+ Default: []
+
false
observedGenerationinteger + Observed generation
+
+ Format: int64
+ Default: 0
+
false
+ + +### SonarrDownloadClientConfig.status.conditions[index] +[↩ Parent](#sonarrdownloadclientconfigstatus) + + + +Condition contains details for one aspect of the current state of this API Resource. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
lastTransitionTimestring + lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+
+ Format: date-time
+
true
messagestring + message is a human readable message indicating details about the transition. This may be an empty string.
+
true
reasonstring + reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty.
+
true
statusstring + status of the condition, one of True, False, Unknown.
+
true
typestring + type of condition in CamelCase or in foo.example.com/CamelCase.
+
true
observedGenerationinteger + observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance.
+
+ Format: int64
+
false
+ +## SonarrImportList +[↩ Parent](#devopsarriov1alpha1 ) + + + + + + +Auto-generated derived type for SonarrImportListSpec via `CustomResource` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
apiVersionstringdevopsarr.io/v1alpha1true
kindstringSonarrImportListtrue
metadataobjectRefer to the Kubernetes API documentation for the fields of the `metadata` field.true
specobject + SonarrImportList represents an import list configuration in Sonarr +Import lists automatically add series from external sources (Trakt, Plex, etc.)
+
true
statusobject +
+
false
+ + +### SonarrImportList.spec +[↩ Parent](#sonarrimportlist) + + + +SonarrImportList represents an import list configuration in Sonarr +Import lists automatically add series from external sources (Trakt, Plex, etc.) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
listTypeenum + Import list type/implementation
+
+ Enum: sonarrImport, traktListImport, traktUserImport, traktPopularImport, plexImport, imdbListImport, customImport, simklImport, aniListImport, myAnimeListImport
+
true
namestring + Import list name
+
true
qualityProfileIdinteger + Quality profile ID to use
+
+ Format: int32
+
true
rootFolderPathstring + Root folder path for imported series
+
true
sonarrInstanceRefobject + Reference to the SonarrInstance
+
true
configobject + Import list configuration
+
+ Default: map[accessToken: apiKey: authUser: baseUrl: languageProfileId: listId: listname: profileIds:[] tagIds:[] traktListType: username:]
+
false
enableAutomaticAddboolean + Enable automatic add
+
+ Default: true
+
false
listOrderinteger + List order
+
+ Format: int32
+ Default: 0
+
false
monitorNewItemsenum + Monitor new items
+
+ Enum: all, none
+ Default: all
+
false
searchForMissingEpisodesboolean + Search for missing episodes when adding
+
+ Default: false
+
false
seasonFolderboolean + Use season folders
+
+ Default: true
+
false
seriesTypeenum + Series type
+
+ Enum: standard, daily, anime
+ Default: standard
+
false
shouldMonitorenum + Monitor type for imported series
+
+ Enum: all, future, missing, existing, firstSeason, latestSeason, pilot, monitorSpecials, unmonitorSpecials, none
+ Default: all
+
false
tags[]integer + Tags for imported series
+
+ Default: []
+
false
+ + +### SonarrImportList.spec.sonarrInstanceRef +[↩ Parent](#sonarrimportlistspec) + + + +Reference to the SonarrInstance + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring + Name of the SonarrInstance resource
+
+ Default:
+
false
namespacestring + Namespace of the SonarrInstance (optional, defaults to same namespace)
+
false
+ + +### SonarrImportList.spec.config +[↩ Parent](#sonarrimportlistspec) + + + +Import list configuration + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
accessTokenstring + Access token (for Trakt/Plex)
+
false
apiKeystring + API key (for Sonarr import)
+
false
authUserstring + Auth user (for Trakt)
+
false
baseUrlstring + Base URL (for Sonarr import)
+
false
languageProfileIdinteger + Language profile ID (deprecated in v4)
+
+ Format: int32
+
false
listIdstring + List ID
+
false
listnamestring + List name/ID
+
false
profileIds[]integer + Profile IDs (for Sonarr import)
+
+ Default: []
+
false
tagIds[]integer + Tag IDs (for Sonarr import)
+
+ Default: []
+
false
traktListTypeinteger + Trakt list type
+
+ Format: int32
+
false
usernamestring + Username (for various services)
+
false
+ + +### SonarrImportList.status +[↩ Parent](#sonarrimportlist) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
conditions[]object + Current conditions
+
+ Default: []
+
false
idinteger + Sonarr Import List ID
+
+ Format: int32
+
false
observedGenerationinteger + Observed generation
+
+ Format: int64
+ Default: 0
+
false
+ + +### SonarrImportList.status.conditions[index] +[↩ Parent](#sonarrimportliststatus) + + + +Condition contains details for one aspect of the current state of this API Resource. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
lastTransitionTimestring + lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+
+ Format: date-time
+
true
messagestring + message is a human readable message indicating details about the transition. This may be an empty string.
+
true
reasonstring + reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty.
+
true
statusstring + status of the condition, one of True, False, Unknown.
+
true
typestring + type of condition in CamelCase or in foo.example.com/CamelCase.
+
true
observedGenerationinteger + observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance.
+
+ Format: int64
+
false
+ +## SonarrIndexer +[↩ Parent](#devopsarriov1alpha1 ) + + + + + + +Auto-generated derived type for SonarrIndexerSpec via `CustomResource` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
apiVersionstringdevopsarr.io/v1alpha1true
kindstringSonarrIndexertrue
metadataobjectRefer to the Kubernetes API documentation for the fields of the `metadata` field.true
specobject + SonarrIndexer represents an indexer configuration in Sonarr +Indexers are sources for finding releases (Newznab, Torznab, etc.)
+
true
statusobject +
+
false
+ + +### SonarrIndexer.spec +[↩ Parent](#sonarrindexer) + + + +SonarrIndexer represents an indexer configuration in Sonarr +Indexers are sources for finding releases (Newznab, Torznab, etc.) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
configobject + Indexer-specific configuration
+
true
indexerTypeenum + Indexer type (Newznab, Torznab, etc.)
+
+ Enum: newznab, torznab, fanzub, broadcasthenet, filelist, hdbits, iptorrents, nyaa, torrentrss, torrentleech
+
true
namestring + Indexer name
+
true
sonarrInstanceRefobject + Reference to the SonarrInstance
+
true
downloadClientIdinteger + Download client ID to use
+
+ Format: int32
+
false
enableAutomaticSearchboolean + Enable automatic search
+
+ Default: true
+
false
enableInteractiveSearchboolean + Enable interactive search
+
+ Default: true
+
false
enableRssboolean + Enable RSS feeds
+
+ Default: true
+
false
priorityinteger + Priority for this indexer
+
+ Format: int32
+ Default: 25
+
false
tags[]integer + Tags for this indexer
+
+ Default: []
+
false
+ + +### SonarrIndexer.spec.config +[↩ Parent](#sonarrindexerspec) + + + +Indexer-specific configuration + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
additionalParametersstring + Additional parameters
+
false
animeCategories[]integer + Anime categories
+
+ Default: []
+
false
animeStandardFormatSearchboolean + Search anime in standard format
+
+ Default: false
+
false
apiKeystring + API key (can reference a secret)
+
false
apiKeySecretRefobject + API key from secret reference
+
false
apiPathstring + API path (default: /api)
+
false
baseUrlstring + Base URL for the indexer
+
false
categories[]integer + Categories to search
+
+ Default: []
+
false
cookiestring + Cookie (for some indexers)
+
false
minimumSeedersinteger + Minimum seeders (for torrent indexers)
+
+ Format: int32
+
false
passkeystring + Passkey (for some indexers)
+
false
passwordSecretRefobject + Password secret reference (for some indexers)
+
false
seedRationumber + Seed ratio (for torrent indexers)
+
+ Format: double
+
false
seedTimeinteger + Seed time (for torrent indexers)
+
+ Format: int32
+
false
usernamestring + Username (for some indexers)
+
false
+ + +### SonarrIndexer.spec.config.apiKeySecretRef +[↩ Parent](#sonarrindexerspecconfig) + + + +API key from secret reference + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
keystring + Key in the secret
+
true
namestring + Name of the secret
+
true
+ + +### SonarrIndexer.spec.config.passwordSecretRef +[↩ Parent](#sonarrindexerspecconfig) + + + +Password secret reference (for some indexers) + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
keystring + Key in the secret
+
true
namestring + Name of the secret
+
true
+ + +### SonarrIndexer.spec.sonarrInstanceRef +[↩ Parent](#sonarrindexerspec) + + + +Reference to the SonarrInstance + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring + Name of the SonarrInstance resource
+
+ Default:
+
false
namespacestring + Namespace of the SonarrInstance (optional, defaults to same namespace)
+
false
+ + +### SonarrIndexer.status +[↩ Parent](#sonarrindexer) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
conditions[]object + Current conditions
+
+ Default: []
+
false
idinteger + Sonarr Indexer ID
+
+ Format: int32
+
false
observedGenerationinteger + Observed generation
+
+ Format: int64
+ Default: 0
+
false
+ + +### SonarrIndexer.status.conditions[index] +[↩ Parent](#sonarrindexerstatus) + + + +Condition contains details for one aspect of the current state of this API Resource. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
lastTransitionTimestring + lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+
+ Format: date-time
+
true
messagestring + message is a human readable message indicating details about the transition. This may be an empty string.
+
true
reasonstring + reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty.
+
true
statusstring + status of the condition, one of True, False, Unknown.
+
true
typestring + type of condition in CamelCase or in foo.example.com/CamelCase.
+
true
observedGenerationinteger + observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance.
+
+ Format: int64
+
false
+ +## SonarrIndexerConfig +[↩ Parent](#devopsarriov1alpha1 ) + + + + + + +Auto-generated derived type for SonarrIndexerConfigSpec via `CustomResource` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
apiVersionstringdevopsarr.io/v1alpha1true
kindstringSonarrIndexerConfigtrue
metadataobjectRefer to the Kubernetes API documentation for the fields of the `metadata` field.true
specobject + SonarrIndexerConfig configures global indexer settings for a Sonarr instance. +Only one SonarrIndexerConfig per Sonarr instance is allowed. +Note: This is different from SonarrIndexer which configures individual indexers.
+
true
statusobject +
+
false
+ + +### SonarrIndexerConfig.spec +[↩ Parent](#sonarrindexerconfig) + + + +SonarrIndexerConfig configures global indexer settings for a Sonarr instance. +Only one SonarrIndexerConfig per Sonarr instance is allowed. +Note: This is different from SonarrIndexer which configures individual indexers. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
sonarrInstanceRefobject + Reference to the Sonarr instance
+
true
maximumSizeinteger + Maximum release size in MB (0 = unlimited)
+
+ Format: int32
+
false
minimumAgeinteger + Minimum age in minutes before downloading (usenet)
+
+ Format: int32
+
false
retentioninteger + Retention in days (0 = unlimited)
+
+ Format: int32
+
false
rssSyncIntervalinteger + RSS sync interval in minutes (0 = disabled, minimum 10)
+
+ Format: int32
+
false
+ + +### SonarrIndexerConfig.spec.sonarrInstanceRef +[↩ Parent](#sonarrindexerconfigspec) + + + +Reference to the Sonarr instance + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring + Name of the SonarrInstance resource
+
+ Default:
+
false
namespacestring + Namespace of the SonarrInstance (optional, defaults to same namespace)
+
false
+ + +### SonarrIndexerConfig.status +[↩ Parent](#sonarrindexerconfig) + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
conditions[]object + Current conditions
+
+ Default: []
+
false
observedGenerationinteger + Observed generation
+
+ Format: int64
+ Default: 0
+
false
+ + +### SonarrIndexerConfig.status.conditions[index] +[↩ Parent](#sonarrindexerconfigstatus) + + + +Condition contains details for one aspect of the current state of this API Resource. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
lastTransitionTimestring + lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+
+ Format: date-time
+
true
messagestring + message is a human readable message indicating details about the transition. This may be an empty string.
+
true
reasonstring + reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty.
+
true
statusstring + status of the condition, one of True, False, Unknown.
+
true
typestring + type of condition in CamelCase or in foo.example.com/CamelCase.
+
true
observedGenerationinteger + observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance.
+
+ Format: int64
+
false
+ +## SonarrLanguageProfile +[↩ Parent](#devopsarriov1alpha1 ) + + + + + + +Auto-generated derived type for SonarrLanguageProfileSpec via `CustomResource` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
apiVersionstringdevopsarr.io/v1alpha1true
kindstringSonarrLanguageProfiletrue
metadataobjectRefer to the Kubernetes API documentation for the fields of the `metadata` field.true
specobject + SonarrLanguageProfile represents a language profile configuration in Sonarr +Language profiles define preferred languages for downloading series +Note: Deprecated in Sonarr v4, replaced by per-series language selection
+
true
statusobject +
+
false
+ + +### SonarrLanguageProfile.spec +[↩ Parent](#sonarrlanguageprofile) + + + +SonarrLanguageProfile represents a language profile configuration in Sonarr +Language profiles define preferred languages for downloading series +Note: Deprecated in Sonarr v4, replaced by per-series language selection + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
cutoffLanguageenum + Cutoff language - stop upgrading when this language is reached
+
+ Enum: Unknown, English, French, Spanish, German, Italian, Danish, Dutch, Japanese, Icelandic, Chinese, Russian, Polish, Vietnamese, Swedish, Norwegian, Finnish, Turkish, Portuguese, Flemish, Greek, Korean, Hungarian, Hebrew, Lithuanian, Czech, Hindi, Romanian, Thai, Bulgarian, PortugueseBrazil, Arabic, Ukrainian, Persian, Bengali, Slovak, Latvian, SpanishLatino, Catalan, Croatian, Serbian, Bosnian, Estonian, Tamil, Indonesian, Telugu, Macedonian, Slovenian, Malay, Original, Any
+
true
languages[]object + Ordered list of languages (first = highest priority)
+
true
namestring + Language profile name
+
true
sonarrInstanceRefobject + Reference to the SonarrInstance
+
true
upgradeAllowedboolean + Allow upgrades to better quality languages
+
+ Default: false
+
false
+ + +### SonarrLanguageProfile.spec.languages[index] +[↩ Parent](#sonarrlanguageprofilespec) + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
languageenum + Language
+
+ Enum: Unknown, English, French, Spanish, German, Italian, Danish, Dutch, Japanese, Icelandic, Chinese, Russian, Polish, Vietnamese, Swedish, Norwegian, Finnish, Turkish, Portuguese, Flemish, Greek, Korean, Hungarian, Hebrew, Lithuanian, Czech, Hindi, Romanian, Thai, Bulgarian, PortugueseBrazil, Arabic, Ukrainian, Persian, Bengali, Slovak, Latvian, SpanishLatino, Catalan, Croatian, Serbian, Bosnian, Estonian, Tamil, Indonesian, Telugu, Macedonian, Slovenian, Malay, Original, Any
+
true
allowedboolean + Whether this language is allowed
+
+ Default: true
+
false
+ + +### SonarrLanguageProfile.spec.sonarrInstanceRef +[↩ Parent](#sonarrlanguageprofilespec) + + + +Reference to the SonarrInstance + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring + Name of the SonarrInstance resource
+
+ Default:
+
false
namespacestring + Namespace of the SonarrInstance (optional, defaults to same namespace)
+
false
+ + +### SonarrLanguageProfile.status +[↩ Parent](#sonarrlanguageprofile) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
conditions[]object + Current conditions
+
+ Default: []
+
false
idinteger + Sonarr Language Profile ID
+
+ Format: int32
+
false
observedGenerationinteger + Observed generation
+
+ Format: int64
+ Default: 0
+
false
+ + +### SonarrLanguageProfile.status.conditions[index] +[↩ Parent](#sonarrlanguageprofilestatus) + + + +Condition contains details for one aspect of the current state of this API Resource. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
lastTransitionTimestring + lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+
+ Format: date-time
+
true
messagestring + message is a human readable message indicating details about the transition. This may be an empty string.
+
true
reasonstring + reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty.
+
true
statusstring + status of the condition, one of True, False, Unknown.
+
true
typestring + type of condition in CamelCase or in foo.example.com/CamelCase.
+
true
observedGenerationinteger + observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance.
+
+ Format: int64
+
false
+ +## SonarrMediaManagementConfig +[↩ Parent](#devopsarriov1alpha1 ) + + + + + + +Auto-generated derived type for SonarrMediaManagementConfigSpec via `CustomResource` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
apiVersionstringdevopsarr.io/v1alpha1true
kindstringSonarrMediaManagementConfigtrue
metadataobjectRefer to the Kubernetes API documentation for the fields of the `metadata` field.true
specobject + SonarrMediaManagementConfig configures media management settings for a Sonarr instance. +Only one SonarrMediaManagementConfig per Sonarr instance is allowed.
+
true
statusobject +
+
false
+ + +### SonarrMediaManagementConfig.spec +[↩ Parent](#sonarrmediamanagementconfig) + + + +SonarrMediaManagementConfig configures media management settings for a Sonarr instance. +Only one SonarrMediaManagementConfig per Sonarr instance is allowed. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
sonarrInstanceRefobject + Reference to the Sonarr instance
+
true
autoUnmonitorPreviouslyDownloadedEpisodesboolean + Auto unmonitor previously downloaded episodes when marked as deleted
+
false
chmodFolderstring + chmod folder permissions (e.g., "755")
+
false
chownGroupstring + chown group
+
false
copyUsingHardlinksboolean + Use hardlinks instead of copy when possible
+
false
createEmptySeriesFoldersboolean + Create empty series folders during disk scan
+
false
deleteEmptyFoldersboolean + Delete empty series and season folders during disk scan
+
false
downloadPropersAndRepacksenum + Download propers and repacks: DoNotPrefer, PreferAndUpgrade, DoNotUpgrade
+
+ Enum: DoNotPrefer, PreferAndUpgrade, DoNotUpgrade
+
false
enableMediaInfoboolean + Enable media info scanning
+
false
episodeTitleRequiredenum + Episode title required: Always, BulkSeasonReleases, Never
+
+ Enum: Always, BulkSeasonReleases, Never
+
false
extraFileExtensionsstring + Extra file extensions to import (e.g., "srt,sub")
+
false
fileDateenum + File date to use: None, LocalAirDate, UtcAirDate
+
+ Enum: None, LocalAirDate, UtcAirDate
+
false
importExtraFilesboolean + Import extra files (subtitles, etc.)
+
false
minimumFreeSpaceWhenImportinginteger + Minimum free space when importing (MB)
+
+ Format: int32
+
false
recycleBinstring + Recycle bin path (empty to disable)
+
false
recycleBinCleanupDaysinteger + Days to keep files in recycle bin before cleaning (0 to disable)
+
+ Format: int32
+
false
rescanAfterRefreshenum + Rescan series folder after refresh: Always, AfterManual, Never
+
+ Enum: Always, AfterManual, Never
+
false
scriptImportPathstring + Script import path
+
false
setPermissionsLinuxboolean + Set permissions on Linux/macOS
+
false
skipFreeSpaceCheckWhenImportingboolean + Skip free space check when importing
+
false
useScriptImportboolean + Use script for importing
+
false
+ + +### SonarrMediaManagementConfig.spec.sonarrInstanceRef +[↩ Parent](#sonarrmediamanagementconfigspec) + + + +Reference to the Sonarr instance + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring + Name of the SonarrInstance resource
+
+ Default:
+
false
namespacestring + Namespace of the SonarrInstance (optional, defaults to same namespace)
+
false
+ + +### SonarrMediaManagementConfig.status +[↩ Parent](#sonarrmediamanagementconfig) + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
conditions[]object + Current conditions
+
+ Default: []
+
false
observedGenerationinteger + Observed generation
+
+ Format: int64
+ Default: 0
+
false
+ + +### SonarrMediaManagementConfig.status.conditions[index] +[↩ Parent](#sonarrmediamanagementconfigstatus) + + + +Condition contains details for one aspect of the current state of this API Resource. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
lastTransitionTimestring + lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+
+ Format: date-time
+
true
messagestring + message is a human readable message indicating details about the transition. This may be an empty string.
+
true
reasonstring + reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty.
+
true
statusstring + status of the condition, one of True, False, Unknown.
+
true
typestring + type of condition in CamelCase or in foo.example.com/CamelCase.
+
true
observedGenerationinteger + observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance.
+
+ Format: int64
+
false
+ +## SonarrMetadata +[↩ Parent](#devopsarriov1alpha1 ) + + + + + + +Auto-generated derived type for SonarrMetadataSpec via `CustomResource` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
apiVersionstringdevopsarr.io/v1alpha1true
kindstringSonarrMetadatatrue
metadataobjectRefer to the Kubernetes API documentation for the fields of the `metadata` field.true
specobject + SonarrMetadata represents a metadata consumer configuration in Sonarr +Metadata consumers write metadata files for media managers (Kodi, Plex, etc.)
+
true
statusobject +
+
false
+ + +### SonarrMetadata.spec +[↩ Parent](#sonarrmetadata) + + + +SonarrMetadata represents a metadata consumer configuration in Sonarr +Metadata consumers write metadata files for media managers (Kodi, Plex, etc.) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
metadataTypeenum + Metadata type/implementation
+
+ Enum: xbmcMetadata, roksboxMetadata, wdtvMetadata
+
true
namestring + Metadata consumer name
+
true
sonarrInstanceRefobject + Reference to the SonarrInstance
+
true
configobject + Metadata-specific configuration
+
+ Default: map[episodeImages:false episodeMetadata:false seasonImages:false seriesImages:false seriesMetadata:false seriesMetadataUrl:false]
+
false
enableboolean + Enable this metadata consumer
+
+ Default: true
+
false
tags[]integer + Tags for this metadata consumer
+
+ Default: []
+
false
+ + +### SonarrMetadata.spec.sonarrInstanceRef +[↩ Parent](#sonarrmetadataspec) + + + +Reference to the SonarrInstance + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring + Name of the SonarrInstance resource
+
+ Default:
+
false
namespacestring + Namespace of the SonarrInstance (optional, defaults to same namespace)
+
false
+ + +### SonarrMetadata.spec.config +[↩ Parent](#sonarrmetadataspec) + + + +Metadata-specific configuration + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
episodeImagesboolean + Write episode images (thumbnails)
+
+ Default: false
+
false
episodeMetadataboolean + Write episode metadata (episode.nfo)
+
+ Default: true
+
false
seasonImagesboolean + Write season images
+
+ Default: true
+
false
seriesImagesboolean + Write series images (poster, banner, fanart)
+
+ Default: true
+
false
seriesMetadataboolean + Write series metadata (series.nfo)
+
+ Default: true
+
false
seriesMetadataUrlboolean + Write series metadata URL (deprecated)
+
+ Default: false
+
false
+ + +### SonarrMetadata.status +[↩ Parent](#sonarrmetadata) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
conditions[]object + Current conditions
+
+ Default: []
+
false
idinteger + Sonarr Metadata ID
+
+ Format: int32
+
false
observedGenerationinteger + Observed generation
+
+ Format: int64
+ Default: 0
+
false
+ + +### SonarrMetadata.status.conditions[index] +[↩ Parent](#sonarrmetadatastatus) + + + +Condition contains details for one aspect of the current state of this API Resource. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
lastTransitionTimestring + lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+
+ Format: date-time
+
true
messagestring + message is a human readable message indicating details about the transition. This may be an empty string.
+
true
reasonstring + reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty.
+
true
statusstring + status of the condition, one of True, False, Unknown.
+
true
typestring + type of condition in CamelCase or in foo.example.com/CamelCase.
+
true
observedGenerationinteger + observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance.
+
+ Format: int64
+
false
+ +## SonarrNamingConfig +[↩ Parent](#devopsarriov1alpha1 ) + + + + + + +Auto-generated derived type for SonarrNamingConfigSpec via `CustomResource` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
apiVersionstringdevopsarr.io/v1alpha1true
kindstringSonarrNamingConfigtrue
metadataobjectRefer to the Kubernetes API documentation for the fields of the `metadata` field.true
specobject + SonarrNamingConfig configures episode naming settings for a Sonarr instance. +Only one SonarrNamingConfig per Sonarr instance is allowed.
+
true
statusobject +
+
false
+ + +### SonarrNamingConfig.spec +[↩ Parent](#sonarrnamingconfig) + + + +SonarrNamingConfig configures episode naming settings for a Sonarr instance. +Only one SonarrNamingConfig per Sonarr instance is allowed. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
sonarrInstanceRefobject + Reference to the Sonarr instance
+
true
animeEpisodeFormatstring + Anime episode format +Example: "{Series Title} - S{season:00}E{episode:00} - {Episode Title} {Quality Full}"
+
false
colonReplacementFormatinteger + Colon replacement format (0=Delete, 1=Dash, 2=SpaceDash, 3=SpaceDashSpace, 4=Smart)
+
+ Format: int32
+
false
customColonReplacementFormatstring + Custom colon replacement format string
+
false
dailyEpisodeFormatstring + Daily episode format +Example: "{Series Title} - {Air-Date} - {Episode Title} {Quality Full}"
+
false
multiEpisodeStyleinteger + Multi-episode style (0=Extend, 1=Duplicate, 2=Repeat, 3=Scene, 4=Range, 5=PrefixedRange)
+
+ Format: int32
+
false
renameEpisodesboolean + Enable episode renaming
+
false
replaceIllegalCharactersboolean + Replace illegal characters in filenames
+
false
seasonFolderFormatstring + Season folder format +Example: "Season {season}"
+
false
seriesFolderFormatstring + Series folder format +Example: "{Series Title}"
+
false
specialsFolderFormatstring + Specials folder format +Example: "Specials"
+
false
standardEpisodeFormatstring + Standard episode format +Example: "{Series Title} - S{season:00}E{episode:00} - {Episode Title} {Quality Full}"
+
false
+ + +### SonarrNamingConfig.spec.sonarrInstanceRef +[↩ Parent](#sonarrnamingconfigspec) + + + +Reference to the Sonarr instance + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring + Name of the SonarrInstance resource
+
+ Default:
+
false
namespacestring + Namespace of the SonarrInstance (optional, defaults to same namespace)
+
false
+ + +### SonarrNamingConfig.status +[↩ Parent](#sonarrnamingconfig) + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
conditions[]object + Current conditions
+
+ Default: []
+
false
observedGenerationinteger + Observed generation
+
+ Format: int64
+ Default: 0
+
false
+ + +### SonarrNamingConfig.status.conditions[index] +[↩ Parent](#sonarrnamingconfigstatus) + + + +Condition contains details for one aspect of the current state of this API Resource. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
lastTransitionTimestring + lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+
+ Format: date-time
+
true
messagestring + message is a human readable message indicating details about the transition. This may be an empty string.
+
true
reasonstring + reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty.
+
true
statusstring + status of the condition, one of True, False, Unknown.
+
true
typestring + type of condition in CamelCase or in foo.example.com/CamelCase.
+
true
observedGenerationinteger + observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance.
+
+ Format: int64
+
false
+ +## SonarrNotification +[↩ Parent](#devopsarriov1alpha1 ) + + + + + + +Auto-generated derived type for SonarrNotificationSpec via `CustomResource` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
apiVersionstringdevopsarr.io/v1alpha1true
kindstringSonarrNotificationtrue
metadataobjectRefer to the Kubernetes API documentation for the fields of the `metadata` field.true
specobject + SonarrNotification represents a notification/connect configuration in Sonarr +Notifications are used to alert on events (Discord, Telegram, Webhook, etc.)
+
true
statusobject +
+
false
+ + +### SonarrNotification.spec +[↩ Parent](#sonarrnotification) + + + +SonarrNotification represents a notification/connect configuration in Sonarr +Notifications are used to alert on events (Discord, Telegram, Webhook, etc.) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
configobject + Notification configuration
+
true
namestring + Notification name
+
true
notificationTypeenum + Notification type
+
+ Enum: Apprise, CustomScript, Discord, Email, Emby, Gotify, Join, Kodi, Mailgun, Ntfy, Plex, Prowl, Pushbullet, Pushover, SendGrid, Signal, Simplepush, Slack, SynologyIndexer, Telegram, Trakt, Twitter, Webhook
+
true
sonarrInstanceRefobject + Reference to the SonarrInstance
+
true
tags[]integer + Tags for this notification
+
+ Default: []
+
false
triggersobject + Event triggers
+
+ Default: map[includeHealthWarnings:false onApplicationUpdate:false onDownload:false onEpisodeFileDelete:false onEpisodeFileDeleteForUpgrade:false onGrab:false onHealthIssue:false onHealthRestored:false onImportComplete:false onManualInteractionRequired:false onRename:false onSeriesAdd:false onSeriesDelete:false onUpgrade:false]
+
false
+ + +### SonarrNotification.spec.config +[↩ Parent](#sonarrnotificationspec) + + + +Notification configuration + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
apiKeySecretRefobject + API key secret reference
+
false
appTokenSecretRefobject + Gotify app token secret reference
+
false
argumentsstring + Script arguments
+
false
authTokenSecretRefobject + Auth token secret reference
+
false
avatarstring + Discord avatar
+
false
bcc[]string + BCC addresses
+
+ Default: []
+
false
botTokenSecretRefobject + Telegram bot token secret reference
+
false
cc[]string + CC addresses
+
+ Default: []
+
false
channelstring + Slack channel
+
false
chatIdstring + Telegram chat ID
+
false
clickUrlstring + Click URL
+
false
devices[]string + Device list
+
+ Default: []
+
false
discordUsernamestring + Discord username
+
false
expireinteger + Expire after (seconds)
+
+ Format: int32
+
false
fromstring + From address
+
false
hoststring + Server host
+
false
iconstring + Slack icon
+
false
mapTostring + Notify on specific library sections
+
false
methodinteger + HTTP Method (1 = POST, 2 = PUT)
+
+ Format: int32
+
false
ntfyTags[]string + Ntfy tags
+
+ Default: []
+
false
passwordSecretRefobject + Password secret reference
+
false
pathstring + Path to script
+
false
portinteger + SMTP port
+
+ Format: int32
+
false
priorityinteger + Priority level
+
+ Format: int32
+
false
requireEncryptionboolean + Require encryption
+
+ Default: false
+
false
retryinteger + Retry interval (seconds)
+
+ Format: int32
+
false
sendSilentlyboolean + Send silently
+
+ Default: false
+
false
serverstring + SMTP server
+
false
serverUrlstring + Ntfy server URL
+
false
slackWebhookUrlstring + Slack webhook URL
+
false
soundstring + Sound
+
false
to[]string + To addresses
+
+ Default: []
+
false
topicstring + Ntfy topic
+
false
updateLibraryboolean + Update library
+
+ Default: false
+
false
urlstring + Webhook URL
+
false
useSslboolean + Use SSL
+
+ Default: false
+
false
userKeySecretRefobject + User key secret reference
+
false
usernamestring + Username for basic auth
+
false
webhookUrlstring + Discord webhook URL
+
false
+ + +### SonarrNotification.spec.config.apiKeySecretRef +[↩ Parent](#sonarrnotificationspecconfig) + + + +API key secret reference + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
keystring + Key in the secret
+
true
namestring + Name of the secret
+
true
+ + +### SonarrNotification.spec.config.appTokenSecretRef +[↩ Parent](#sonarrnotificationspecconfig) + + + +Gotify app token secret reference + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
keystring + Key in the secret
+
true
namestring + Name of the secret
+
true
+ + +### SonarrNotification.spec.config.authTokenSecretRef +[↩ Parent](#sonarrnotificationspecconfig) + + + +Auth token secret reference + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
keystring + Key in the secret
+
true
namestring + Name of the secret
+
true
+ + +### SonarrNotification.spec.config.botTokenSecretRef +[↩ Parent](#sonarrnotificationspecconfig) + + + +Telegram bot token secret reference + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
keystring + Key in the secret
+
true
namestring + Name of the secret
+
true
+ + +### SonarrNotification.spec.config.passwordSecretRef +[↩ Parent](#sonarrnotificationspecconfig) + + + +Password secret reference + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
keystring + Key in the secret
+
true
namestring + Name of the secret
+
true
+ + +### SonarrNotification.spec.config.userKeySecretRef +[↩ Parent](#sonarrnotificationspecconfig) + + + +User key secret reference + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
keystring + Key in the secret
+
true
namestring + Name of the secret
+
true
+ + +### SonarrNotification.spec.sonarrInstanceRef +[↩ Parent](#sonarrnotificationspec) + + + +Reference to the SonarrInstance + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring + Name of the SonarrInstance resource
+
+ Default:
+
false
namespacestring + Namespace of the SonarrInstance (optional, defaults to same namespace)
+
false
+ + +### SonarrNotification.spec.triggers +[↩ Parent](#sonarrnotificationspec) + + + +Event triggers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
includeHealthWarningsboolean + Include health warnings
+
+ Default: false
+
false
onApplicationUpdateboolean + On application update
+
+ Default: false
+
false
onDownloadboolean + On download (episode is downloaded)
+
+ Default: false
+
false
onEpisodeFileDeleteboolean + On episode file delete
+
+ Default: false
+
false
onEpisodeFileDeleteForUpgradeboolean + On episode file delete for upgrade
+
+ Default: false
+
false
onGrabboolean + On grab (episode is grabbed)
+
+ Default: false
+
false
onHealthIssueboolean + On health issue
+
+ Default: false
+
false
onHealthRestoredboolean + On health restored
+
+ Default: false
+
false
onImportCompleteboolean + On import complete
+
+ Default: false
+
false
onManualInteractionRequiredboolean + On manual interaction required
+
+ Default: false
+
false
onRenameboolean + On rename
+
+ Default: false
+
false
onSeriesAddboolean + On series add
+
+ Default: false
+
false
onSeriesDeleteboolean + On series delete
+
+ Default: false
+
false
onUpgradeboolean + On upgrade (episode is upgraded)
+
+ Default: false
+
false
+ + +### SonarrNotification.status +[↩ Parent](#sonarrnotification) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
conditions[]object + Current conditions
+
+ Default: []
+
false
idinteger + Sonarr Notification ID
+
+ Format: int32
+
false
observedGenerationinteger + Observed generation
+
+ Format: int64
+ Default: 0
+
false
+ + +### SonarrNotification.status.conditions[index] +[↩ Parent](#sonarrnotificationstatus) + + + +Condition contains details for one aspect of the current state of this API Resource. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
lastTransitionTimestring + lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+
+ Format: date-time
+
true
messagestring + message is a human readable message indicating details about the transition. This may be an empty string.
+
true
reasonstring + reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty.
+
true
statusstring + status of the condition, one of True, False, Unknown.
+
true
typestring + type of condition in CamelCase or in foo.example.com/CamelCase.
+
true
observedGenerationinteger + observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance.
+
+ Format: int64
+
false
+ +## SonarrQualityDefinition +[↩ Parent](#devopsarriov1alpha1 ) + + + + + + +Auto-generated derived type for SonarrQualityDefinitionSpec via `CustomResource` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
apiVersionstringdevopsarr.io/v1alpha1true
kindstringSonarrQualityDefinitiontrue
metadataobjectRefer to the Kubernetes API documentation for the fields of the `metadata` field.true
specobject + SonarrQualityDefinition represents a quality definition configuration in Sonarr +Quality definitions control the size limits for each quality level
+
true
statusobject +
+
false
+ + +### SonarrQualityDefinition.spec +[↩ Parent](#sonarrqualitydefinition) + + + +SonarrQualityDefinition represents a quality definition configuration in Sonarr +Quality definitions control the size limits for each quality level + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
qualityNameenum + Quality name (must match existing quality in Sonarr)
+
+ Enum: UNKNOWN, SDTV, DVD, WEBDL-480p, WEBRip-480p, Bluray-480p, HDTV-720p, HDTV-1080p, Raw-HD, WEBDL-720p, WEBRip-720p, Bluray-720p, WEBDL-1080p, WEBRip-1080p, Bluray-1080p, Bluray-1080p Remux, HDTV-2160p, WEBDL-2160p, WEBRip-2160p, Bluray-2160p, Bluray-2160p Remux
+
true
sonarrInstanceRefobject + Reference to the SonarrInstance
+
true
maxSizenumber + Maximum size in MB per minute of runtime (None = unlimited)
+
+ Format: double
+
false
minSizenumber + Minimum size in MB per minute of runtime
+
+ Format: double
+
false
preferredSizenumber + Preferred size in MB per minute of runtime
+
+ Format: double
+
false
titlestring + Title/display name for this quality
+
false
+ + +### SonarrQualityDefinition.spec.sonarrInstanceRef +[↩ Parent](#sonarrqualitydefinitionspec) + + + +Reference to the SonarrInstance + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring + Name of the SonarrInstance resource
+
+ Default:
+
false
namespacestring + Namespace of the SonarrInstance (optional, defaults to same namespace)
+
false
+ + +### SonarrQualityDefinition.status +[↩ Parent](#sonarrqualitydefinition) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
conditions[]object + Current conditions
+
+ Default: []
+
false
idinteger + Sonarr Quality Definition ID
+
+ Format: int32
+
false
observedGenerationinteger + Observed generation
+
+ Format: int64
+ Default: 0
+
false
+ + +### SonarrQualityDefinition.status.conditions[index] +[↩ Parent](#sonarrqualitydefinitionstatus) + + + +Condition contains details for one aspect of the current state of this API Resource. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
lastTransitionTimestring + lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+
+ Format: date-time
+
true
messagestring + message is a human readable message indicating details about the transition. This may be an empty string.
+
true
reasonstring + reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty.
+
true
statusstring + status of the condition, one of True, False, Unknown.
+
true
typestring + type of condition in CamelCase or in foo.example.com/CamelCase.
+
true
observedGenerationinteger + observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance.
+
+ Format: int64
+
false
+ +## SonarrQualityProfile +[↩ Parent](#devopsarriov1alpha1 ) + + + + + + +Auto-generated derived type for SonarrQualityProfileSpec via `CustomResource` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
apiVersionstringdevopsarr.io/v1alpha1true
kindstringSonarrQualityProfiletrue
metadataobjectRefer to the Kubernetes API documentation for the fields of the `metadata` field.true
specobject + SonarrQualityProfile represents a quality profile in Sonarr +Quality profiles define which qualities are acceptable and their priority
+
true
statusobject +
+
false
+ + +### SonarrQualityProfile.spec +[↩ Parent](#sonarrqualityprofile) + + + +SonarrQualityProfile represents a quality profile in Sonarr +Quality profiles define which qualities are acceptable and their priority + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring + Quality profile name
+
true
qualityGroups[]object + Ordered list of quality groups
+
true
sonarrInstanceRefobject + Reference to the SonarrInstance
+
true
cutoffinteger + Quality ID to use as cutoff
+
+ Format: int32
+ Default: 0
+
false
cutoffFormatScoreinteger + Cutoff format score
+
+ Format: int32
+
false
formatItems[]object + Format items (custom formats with scores)
+
+ Default: []
+
false
minFormatScoreinteger + Minimum format score
+
+ Format: int32
+
false
minUpgradeFormatScoreinteger + Minimum upgrade format score
+
+ Format: int32
+
false
upgradeAllowedboolean + Whether upgrades are allowed
+
+ Default: false
+
false
+ + +### SonarrQualityProfile.spec.qualityGroups[index] +[↩ Parent](#sonarrqualityprofilespec) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
qualities[]object + Ordered list of qualities in this group
+
true
idinteger + Quality group ID
+
+ Format: int32
+
false
namestring + Quality group name
+
false
+ + +### SonarrQualityProfile.spec.qualityGroups[index].qualities[index] +[↩ Parent](#sonarrqualityprofilespecqualitygroupsindex) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
idinteger + Quality ID
+
+ Format: int32
+
false
namestring + Quality name
+
false
resolutioninteger + Resolution
+
+ Format: int32
+
false
sourcestring + Source type
+
false
+ + +### SonarrQualityProfile.spec.sonarrInstanceRef +[↩ Parent](#sonarrqualityprofilespec) + + + +Reference to the SonarrInstance + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring + Name of the SonarrInstance resource
+
+ Default:
+
false
namespacestring + Namespace of the SonarrInstance (optional, defaults to same namespace)
+
false
+ + +### SonarrQualityProfile.spec.formatItems[index] +[↩ Parent](#sonarrqualityprofilespec) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
formatinteger + Custom format ID
+
+ Format: int32
+
false
namestring + Format name
+
false
scoreinteger + Score for this format
+
+ Format: int32
+ Default: 0
+
false
+ + +### SonarrQualityProfile.status +[↩ Parent](#sonarrqualityprofile) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
conditions[]object + Current conditions
+
+ Default: []
+
false
idinteger + Sonarr Quality Profile ID
+
+ Format: int32
+
false
observedGenerationinteger + Observed generation
+
+ Format: int64
+ Default: 0
+
false
+ + +### SonarrQualityProfile.status.conditions[index] +[↩ Parent](#sonarrqualityprofilestatus) + + + +Condition contains details for one aspect of the current state of this API Resource. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
lastTransitionTimestring + lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+
+ Format: date-time
+
true
messagestring + message is a human readable message indicating details about the transition. This may be an empty string.
+
true
reasonstring + reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty.
+
true
statusstring + status of the condition, one of True, False, Unknown.
+
true
typestring + type of condition in CamelCase or in foo.example.com/CamelCase.
+
true
observedGenerationinteger + observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance.
+
+ Format: int64
+
false
+ +## SonarrRootFolder +[↩ Parent](#devopsarriov1alpha1 ) + + + + + + +Auto-generated derived type for SonarrRootFolderSpec via `CustomResource` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
apiVersionstringdevopsarr.io/v1alpha1true
kindstringSonarrRootFoldertrue
metadataobjectRefer to the Kubernetes API documentation for the fields of the `metadata` field.true
specobject + SonarrRootFolder represents a root folder in Sonarr +Root folders are the base directories where series are stored
+
true
statusobject +
+
false
+ + +### SonarrRootFolder.spec +[↩ Parent](#sonarrrootfolder) + + + +SonarrRootFolder represents a root folder in Sonarr +Root folders are the base directories where series are stored + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
pathstring + Root folder absolute path
+
true
sonarrInstanceRefobject + Reference to the SonarrInstance
+
true
+ + +### SonarrRootFolder.spec.sonarrInstanceRef +[↩ Parent](#sonarrrootfolderspec) + + + +Reference to the SonarrInstance + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring + Name of the SonarrInstance resource
+
+ Default:
+
false
namespacestring + Namespace of the SonarrInstance (optional, defaults to same namespace)
+
false
+ + +### SonarrRootFolder.status +[↩ Parent](#sonarrrootfolder) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
accessibleboolean + Whether the folder is accessible
+
false
conditions[]object + Current conditions
+
+ Default: []
+
false
freeSpaceinteger + Free space in the folder
+
+ Format: int64
+
false
idinteger + Sonarr Root Folder ID
+
+ Format: int32
+
false
observedGenerationinteger + Observed generation
+
+ Format: int64
+ Default: 0
+
false
+ + +### SonarrRootFolder.status.conditions[index] +[↩ Parent](#sonarrrootfolderstatus) + + + +Condition contains details for one aspect of the current state of this API Resource. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
lastTransitionTimestring + lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+
+ Format: date-time
+
true
messagestring + message is a human readable message indicating details about the transition. This may be an empty string.
+
true
reasonstring + reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty.
+
true
statusstring + status of the condition, one of True, False, Unknown.
+
true
typestring + type of condition in CamelCase or in foo.example.com/CamelCase.
+
true
observedGenerationinteger + observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance.
+
+ Format: int64
+
false
+ +## SonarrSeries +[↩ Parent](#devopsarriov1alpha1 ) + + + + + + +Auto-generated derived type for SonarrSeriesSpec via `CustomResource` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
apiVersionstringdevopsarr.io/v1alpha1true
kindstringSonarrSeriestrue
metadataobjectRefer to the Kubernetes API documentation for the fields of the `metadata` field.true
specobject + SonarrSeries represents a TV series managed in Sonarr +This allows declarative management of series in your library
+
true
statusobject +
+
false
+ + +### SonarrSeries.spec +[↩ Parent](#sonarrseries) + + + +SonarrSeries represents a TV series managed in Sonarr +This allows declarative management of series in your library + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
qualityProfileobject + Quality profile ID or name reference
+
true
rootFolderPathstring + Root folder path for the series
+
true
sonarrInstanceRefobject + Reference to the SonarrInstance
+
true
titlestring + Series title
+
true
titleSlugstring + Title slug (kebab-case version of title)
+
true
tvdbIdinteger + TVDB ID for the series
+
+ Format: int32
+
true
addOptionsobject + Monitor type for adding series
+
+ Default: map[monitor:all searchForCutoffUnmetEpisodes:false searchForMissingEpisodes:true]
+
false
monitoredboolean + Whether the series is monitored
+
+ Default: true
+
false
pathstring + Specific path override (optional)
+
false
seasonFolderboolean + Use season folders
+
+ Default: true
+
false
seriesTypeenum + Series type
+
+ Enum: standard, daily, anime
+ Default: standard
+
false
tags[]integer + Tags for this series
+
+ Default: []
+
false
useSceneNumberingboolean + Use scene numbering
+
+ Default: false
+
false
+ + +### SonarrSeries.spec.qualityProfile +[↩ Parent](#sonarrseriesspec) + + + +Quality profile ID or name reference + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
idinteger + Quality profile ID
+
+ Format: int32
+
false
namestring + Quality profile name (will be resolved to ID)
+
false
+ + +### SonarrSeries.spec.sonarrInstanceRef +[↩ Parent](#sonarrseriesspec) + + + +Reference to the SonarrInstance + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring + Name of the SonarrInstance resource
+
+ Default:
+
false
namespacestring + Namespace of the SonarrInstance (optional, defaults to same namespace)
+
false
+ + +### SonarrSeries.spec.addOptions +[↩ Parent](#sonarrseriesspec) + + + +Monitor type for adding series + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
monitorenum + Monitor type
+
+ Enum: all, future, missing, existing, recent, pilot, firstseason, lastseason, none
+ Default: all
+
false
searchForCutoffUnmetEpisodesboolean + Search for cutoff unmet episodes
+
+ Default: false
+
false
searchForMissingEpisodesboolean + Search for missing episodes when adding
+
+ Default: true
+
false
+ + +### SonarrSeries.status +[↩ Parent](#sonarrseries) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
conditions[]object + Current conditions
+
+ Default: []
+
false
episodeCountinteger + Total episode count
+
+ Format: int32
+
false
episodeFileCountinteger + Episode file count
+
+ Format: int32
+
false
idinteger + Sonarr Series ID
+
+ Format: int32
+
false
networkstring + Network
+
false
nextAiringstring + Next airing date
+
false
observedGenerationinteger + Observed generation
+
+ Format: int64
+ Default: 0
+
false
percentCompletenumber + Percentage complete
+
+ Format: double
+
false
previousAiringstring + Previous airing date
+
false
seriesStatusstring + Status (continuing, ended, etc.)
+
false
+ + +### SonarrSeries.status.conditions[index] +[↩ Parent](#sonarrseriesstatus) + + + +Condition contains details for one aspect of the current state of this API Resource. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
lastTransitionTimestring + lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+
+ Format: date-time
+
true
messagestring + message is a human readable message indicating details about the transition. This may be an empty string.
+
true
reasonstring + reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty.
+
true
statusstring + status of the condition, one of True, False, Unknown.
+
true
typestring + type of condition in CamelCase or in foo.example.com/CamelCase.
+
true
observedGenerationinteger + observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance.
+
+ Format: int64
+
false
+ +## Sonarr +[↩ Parent](#devopsarriov1alpha1 ) + + + + + + +Auto-generated derived type for SonarrSpec via `CustomResource` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
apiVersionstringdevopsarr.io/v1alpha1true
kindstringSonarrtrue
metadataobjectRefer to the Kubernetes API documentation for the fields of the `metadata` field.true
specobject + Sonarr is the main CRD that deploys and manages a Sonarr instance + +This CRD creates: +- A Deployment with the Sonarr container +- An init container for database migrations +- A Service to expose Sonarr +- A PersistentVolumeClaim for configuration storage +- Optional Ingress for external access
+
true
statusobject +
+
false
+ + +### Sonarr.spec +[↩ Parent](#sonarr) + + + +Sonarr is the main CRD that deploys and manages a Sonarr instance + +This CRD creates: +- A Deployment with the Sonarr container +- An init container for database migrations +- A Service to expose Sonarr +- A PersistentVolumeClaim for configuration storage +- Optional Ingress for external access + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
apiKeySecretRefobject + API key secret reference (optional - will be auto-generated if not provided)
+
false
configobject + Sonarr application configuration (config.xml settings)
+
+ Default: map[analyticsEnabled: authenticationMethod: authenticationRequired: bindAddress: initContainerImage: instanceName: logLevel: urlBase:]
+
false
env[]object + Environment variables
+
+ Default: []
+
false
httpRouteobject + HTTPRoute configuration for Gateway API (optional)
+
false
imagestring + Sonarr image to use (default: lscr.io/linuxserver/sonarr:latest)
+
+ Default: lscr.io/linuxserver/sonarr:latest
+
false
imagePullPolicystring + Image pull policy (default: IfNotPresent)
+
+ Default: IfNotPresent
+
false
ingressobject + Ingress configuration (optional)
+
false
initContainerobject + Init container configuration (for custom init logic)
+
false
nodeSelectormap[string]string + Node selector
+
+ Default: map[]
+
false
replicasinteger + Number of replicas (should be 1 for Sonarr)
+
+ Format: int32
+ Default: 1
+
false
resourcesobject + Resource requirements
+
false
securityContextobject + Pod security context
+
false
serviceobject + Service configuration
+
+ Default: map[annotations:map[] containerPort:0 nodePort: port:0 serviceType:]
+
false
storageobject + Storage configuration
+
+ Default: map[accessModes:[] existingClaim: size: storageClass:]
+
false
tolerations[]object + Tolerations
+
+ Default: []
+
false
volumeMounts[]object + Volume mounts for media directories
+
+ Default: []
+
false
volumes[]object + Additional volumes
+
+ Default: []
+
false
+ + +### Sonarr.spec.apiKeySecretRef +[↩ Parent](#sonarrspec) + + + +API key secret reference (optional - will be auto-generated if not provided) + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
keystring + Key in the secret
+
true
namestring + Name of the secret
+
true
+ + +### Sonarr.spec.config +[↩ Parent](#sonarrspec) + + + +Sonarr application configuration (config.xml settings) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
analyticsEnabledboolean + Analytics enabled (default: true)
+
false
authenticationMethodstring + Authentication method: None, Basic, Forms, External (default: None)
+
false
authenticationRequiredboolean + Authentication required for API access (default: false)
+
false
bindAddressstring + Bind address (default: "*")
+
false
initContainerImagestring + Init container image used to configure config.xml (default: busybox:latest)
+
false
instanceNamestring + Instance name displayed in the UI
+
false
logLevelstring + Log level: trace, debug, info, warn, error (default: info)
+
false
urlBasestring + URL base for reverse proxy setups (e.g., "/sonarr")
+
false
+ + +### Sonarr.spec.env[index] +[↩ Parent](#sonarrspec) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring + Name of the environment variable
+
true
valuestring + Value of the environment variable
+
false
valueFromobject + Reference to a secret or configmap
+
false
+ + +### Sonarr.spec.env[index].valueFrom +[↩ Parent](#sonarrspecenvindex) + + + +Reference to a secret or configmap + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
configMapKeyRefobject + ConfigMap key reference
+
false
secretKeyRefobject + Secret key reference
+
false
+ + +### Sonarr.spec.env[index].valueFrom.configMapKeyRef +[↩ Parent](#sonarrspecenvindexvaluefrom) + + + +ConfigMap key reference + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
keystring + Key in the configmap
+
true
namestring + Name of the configmap
+
true
+ + +### Sonarr.spec.env[index].valueFrom.secretKeyRef +[↩ Parent](#sonarrspecenvindexvaluefrom) + + + +Secret key reference + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
keystring + Key in the secret
+
true
namestring + Name of the secret
+
true
+ + +### Sonarr.spec.httpRoute +[↩ Parent](#sonarrspec) + + + +HTTPRoute configuration for Gateway API (optional) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
gatewayRefobject + Gateway reference - the Gateway to attach to
+
true
annotationsmap[string]string + Additional annotations for the HTTPRoute
+
+ Default: map[]
+
false
enabledboolean + Enable HTTPRoute creation (default: false)
+
+ Default: false
+
false
hostnames[]string + Hostnames for the HTTPRoute
+
+ Default: []
+
false
labelsmap[string]string + Additional labels for the HTTPRoute
+
+ Default: map[]
+
false
pathstring + Path match for the route (default: /)
+
+ Default: /
+
false
pathTypestring + Path match type: Exact, PathPrefix, or RegularExpression (default: PathPrefix)
+
+ Default: PathPrefix
+
false
+ + +### Sonarr.spec.httpRoute.gatewayRef +[↩ Parent](#sonarrspechttproute) + + + +Gateway reference - the Gateway to attach to + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring + Name of the Gateway
+
true
namespacestring + Namespace of the Gateway (optional, defaults to same namespace as HTTPRoute)
+
false
sectionNamestring + Section name within the Gateway (optional)
+
false
+ + +### Sonarr.spec.ingress +[↩ Parent](#sonarrspec) + + + +Ingress configuration (optional) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
hoststring + Hostname for the ingress
+
true
annotationsmap[string]string + Ingress annotations
+
+ Default: map[]
+
false
enabledboolean + Enable ingress (default: false)
+
+ Default: false
+
false
ingressClassNamestring + Ingress class name
+
false
pathstring + Path for the ingress (default: /)
+
+ Default: /
+
false
pathTypestring + Path type (default: Prefix)
+
+ Default: Prefix
+
false
tlsobject + TLS configuration
+
false
+ + +### Sonarr.spec.ingress.tls +[↩ Parent](#sonarrspecingress) + + + +TLS configuration + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
secretNamestring + Secret name containing TLS certificate
+
true
hosts[]string + Hosts covered by the TLS certificate
+
+ Default: []
+
false
+ + +### Sonarr.spec.initContainer +[↩ Parent](#sonarrspec) + + + +Init container configuration (for custom init logic) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
args[]string + Arguments for the command
+
+ Default: []
+
false
command[]string + Command to run in init container
+
+ Default: []
+
false
env[]object + Environment variables for init container
+
+ Default: []
+
false
imagestring + Image for init container (default: busybox:latest)
+
+ Default: busybox:latest
+
false
+ + +### Sonarr.spec.initContainer.env[index] +[↩ Parent](#sonarrspecinitcontainer) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring + Name of the environment variable
+
true
valuestring + Value of the environment variable
+
false
valueFromobject + Reference to a secret or configmap
+
false
+ + +### Sonarr.spec.initContainer.env[index].valueFrom +[↩ Parent](#sonarrspecinitcontainerenvindex) + + + +Reference to a secret or configmap + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
configMapKeyRefobject + ConfigMap key reference
+
false
secretKeyRefobject + Secret key reference
+
false
+ + +### Sonarr.spec.initContainer.env[index].valueFrom.configMapKeyRef +[↩ Parent](#sonarrspecinitcontainerenvindexvaluefrom) + + + +ConfigMap key reference + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
keystring + Key in the configmap
+
true
namestring + Name of the configmap
+
true
+ + +### Sonarr.spec.initContainer.env[index].valueFrom.secretKeyRef +[↩ Parent](#sonarrspecinitcontainerenvindexvaluefrom) + + + +Secret key reference + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
keystring + Key in the secret
+
true
namestring + Name of the secret
+
true
+ + +### Sonarr.spec.resources +[↩ Parent](#sonarrspec) + + + +Resource requirements + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
limitsmap[string]string + Resource limits
+
+ Default: map[]
+
false
requestsmap[string]string + Resource requests
+
+ Default: map[]
+
false
+ + +### Sonarr.spec.securityContext +[↩ Parent](#sonarrspec) + + + +Pod security context + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
fsGroupinteger +
+
+ Format: int64
+
false
runAsGroupinteger +
+
+ Format: int64
+
false
runAsNonRootboolean +
+
false
runAsUserinteger +
+
+ Format: int64
+
false
+ + +### Sonarr.spec.service +[↩ Parent](#sonarrspec) + + + +Service configuration + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
annotationsmap[string]string + Service annotations
+
+ Default: map[]
+
false
containerPortinteger + Container port - the port Sonarr listens on inside the container (default: 8989)
+
+ Format: int32
+ Default: 8989
+
false
nodePortinteger + Node port (only for NodePort type)
+
+ Format: int32
+
false
portinteger + Service port (default: 8989)
+
+ Format: int32
+ Default: 8989
+
false
serviceTypestring + Service type (default: ClusterIP)
+
+ Default: ClusterIP
+
false
+ + +### Sonarr.spec.storage +[↩ Parent](#sonarrspec) + + + +Storage configuration + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
accessModes[]string + Access modes (default: ReadWriteOnce)
+
+ Default: [ReadWriteOnce]
+
false
existingClaimstring + Existing PVC to use (optional)
+
false
sizestring + Size of the config PVC (default: 1Gi)
+
+ Default: 1Gi
+
false
storageClassstring + Storage class for the PVC
+
false
+ + +### Sonarr.spec.tolerations[index] +[↩ Parent](#sonarrspec) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
effectstring +
+
false
keystring +
+
false
operatorstring +
+
false
tolerationSecondsinteger +
+
+ Format: int64
+
false
valuestring +
+
false
+ + +### Sonarr.spec.volumeMounts[index] +[↩ Parent](#sonarrspec) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
mountPathstring + Mount path inside the container
+
true
namestring + Name of the volume
+
true
readOnlyboolean + Read only flag
+
+ Default: false
+
false
subPathstring + Sub path (optional)
+
false
+ + +### Sonarr.spec.volumes[index] +[↩ Parent](#sonarrspec) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring + Name of the volume
+
true
configMapobject + ConfigMap volume
+
false
emptyDirobject + Empty dir volume
+
false
hostPathobject + HostPath volume
+
false
nfsobject + NFS volume
+
false
persistentVolumeClaimobject + PVC claim
+
false
+ + +### Sonarr.spec.volumes[index].configMap +[↩ Parent](#sonarrspecvolumesindex) + + + +ConfigMap volume + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring +
+
true
items[]object +
+
+ Default: []
+
false
+ + +### Sonarr.spec.volumes[index].configMap.items[index] +[↩ Parent](#sonarrspecvolumesindexconfigmap) + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
keystring +
+
true
pathstring +
+
true
+ + +### Sonarr.spec.volumes[index].emptyDir +[↩ Parent](#sonarrspecvolumesindex) + + + +Empty dir volume + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
mediumstring +
+
false
sizeLimitstring +
+
false
+ + +### Sonarr.spec.volumes[index].hostPath +[↩ Parent](#sonarrspecvolumesindex) + + + +HostPath volume + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
pathstring +
+
true
typestring +
+
false
+ + +### Sonarr.spec.volumes[index].nfs +[↩ Parent](#sonarrspecvolumesindex) + + + +NFS volume + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
pathstring +
+
true
serverstring +
+
true
readOnlyboolean +
+
+ Default: false
+
false
+ + +### Sonarr.spec.volumes[index].persistentVolumeClaim +[↩ Parent](#sonarrspecvolumesindex) + + + +PVC claim + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
claimNamestring +
+
true
readOnlyboolean +
+
+ Default: false
+
false
+ + +### Sonarr.status +[↩ Parent](#sonarr) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
apiKeySecretstring + API key (stored in secret)
+
false
conditions[]object + Current conditions
+
+ Default: []
+
false
observedGenerationinteger + Observed generation
+
+ Format: int64
+ Default: 0
+
false
readyReplicasinteger + Number of ready replicas
+
+ Format: int32
+ Default: 0
+
false
urlstring + URL to access Sonarr
+
false
versionstring + Sonarr version
+
false
+ + +### Sonarr.status.conditions[index] +[↩ Parent](#sonarrstatus) + + + +Condition contains details for one aspect of the current state of this API Resource. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
lastTransitionTimestring + lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+
+ Format: date-time
+
true
messagestring + message is a human readable message indicating details about the transition. This may be an empty string.
+
true
reasonstring + reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty.
+
true
statusstring + status of the condition, one of True, False, Unknown.
+
true
typestring + type of condition in CamelCase or in foo.example.com/CamelCase.
+
true
observedGenerationinteger + observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance.
+
+ Format: int64
+
false
+ +## SonarrTag +[↩ Parent](#devopsarriov1alpha1 ) + + + + + + +Auto-generated derived type for SonarrTagSpec via `CustomResource` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
apiVersionstringdevopsarr.io/v1alpha1true
kindstringSonarrTagtrue
metadataobjectRefer to the Kubernetes API documentation for the fields of the `metadata` field.true
specobject + SonarrTag represents a tag in Sonarr +Tags are used to organize and filter series, profiles, and other resources
+
true
statusobject +
+
false
+ + +### SonarrTag.spec +[↩ Parent](#sonarrtag) + + + +SonarrTag represents a tag in Sonarr +Tags are used to organize and filter series, profiles, and other resources + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
labelstring + Tag label (must be lowercase)
+
true
sonarrInstanceRefobject + Reference to the SonarrInstance
+
true
+ + +### SonarrTag.spec.sonarrInstanceRef +[↩ Parent](#sonarrtagspec) + + + +Reference to the SonarrInstance + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring + Name of the SonarrInstance resource
+
+ Default:
+
false
namespacestring + Namespace of the SonarrInstance (optional, defaults to same namespace)
+
false
+ + +### SonarrTag.status +[↩ Parent](#sonarrtag) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
conditions[]object + Current conditions
+
+ Default: []
+
false
idinteger + Sonarr Tag ID
+
+ Format: int32
+
false
observedGenerationinteger + Observed generation
+
+ Format: int64
+ Default: 0
+
false
+ + +### SonarrTag.status.conditions[index] +[↩ Parent](#sonarrtagstatus) + + + +Condition contains details for one aspect of the current state of this API Resource. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
lastTransitionTimestring + lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+
+ Format: date-time
+
true
messagestring + message is a human readable message indicating details about the transition. This may be an empty string.
+
true
reasonstring + reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty.
+
true
statusstring + status of the condition, one of True, False, Unknown.
+
true
typestring + type of condition in CamelCase or in foo.example.com/CamelCase.
+
true
observedGenerationinteger + observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance.
+
+ Format: int64
+
false
diff --git a/manifests/crds/sonarr.yaml b/manifests/crds/sonarr.yaml deleted file mode 100644 index 8f63140..0000000 --- a/manifests/crds/sonarr.yaml +++ /dev/null @@ -1,74 +0,0 @@ -apiVersion: apiextensions.k8s.io/v1 -kind: CustomResourceDefinition -metadata: - name: sonarrs.devopsarr.io -spec: - group: devopsarr.io - names: - categories: [] - kind: Sonarr - plural: sonarrs - shortNames: - - son - singular: sonarr - scope: Namespaced - versions: - - additionalPrinterColumns: [] - name: v1 - schema: - openAPIV3Schema: - description: Auto-generated derived type for SonarrSpec via `CustomResource` - properties: - spec: - description: Define the Custom Resource Definition (CRD) for our operator - properties: - message: - type: string - replicaCount: - format: int32 - nullable: true - type: integer - required: - - message - type: object - status: - description: Status object for our CRD - nullable: true - properties: - conditions: - items: - description: Condition type for tracking the status of the resource - properties: - message: - type: string - reason: - type: string - status: - type: string - type: - type: string - required: - - message - - reason - - status - - type - type: object - type: array - observed_generation: - format: int64 - nullable: true - type: integer - ready: - nullable: true - type: boolean - required: - - conditions - type: object - required: - - spec - title: Sonarr - type: object - served: true - storage: true - subresources: - status: {} diff --git a/manifests/deployment.yaml b/manifests/deployment.yaml deleted file mode 100644 index f3f17db..0000000 --- a/manifests/deployment.yaml +++ /dev/null @@ -1,61 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: sonarr-k8s-operator - labels: - app: sonarr-k8s-operator -spec: - replicas: 1 - selector: - matchLabels: - app: sonarr-k8s-operator - template: - metadata: - labels: - app: sonarr-k8s-operator - spec: - serviceAccountName: sonarr-k8s-operator - containers: - - name: operator - image: k8s-operator-sonarr:latest - imagePullPolicy: Never - resources: - limits: - cpu: "500m" - memory: "512Mi" - requests: - cpu: "100m" - memory: "128Mi" ---- -apiVersion: v1 -kind: ServiceAccount -metadata: - name: sonarr-k8s-operator ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - name: sonarr-k8s-operator -rules: -- apiGroups: ["devopsarr.io"] - resources: ["sonarrs"] - verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] -- apiGroups: ["devopsarr.io"] - resources: ["sonarrs/status"] - verbs: ["get", "update", "patch"] -- apiGroups: [""] - resources: ["events"] - verbs: ["create", "patch"] ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding -metadata: - name: sonarr-k8s-operator -subjects: -- kind: ServiceAccount - name: sonarr-k8s-operator - namespace: default -roleRef: - kind: ClusterRole - name: sonarr-k8s-operator - apiGroup: rbac.authorization.k8s.io diff --git a/manifests/example-sonarr-crd.yaml b/manifests/example-sonarr-crd.yaml deleted file mode 100644 index 72272c0..0000000 --- a/manifests/example-sonarr-crd.yaml +++ /dev/null @@ -1,7 +0,0 @@ -apiVersion: devopsarr.io/v1 -kind: Sonarr -metadata: - name: sonarr -spec: - message: "Hello from Rust K8s Operator!" - replicaCount: 3 diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000..9bf32cf --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,73 @@ +//! Sonarr API client factory using the sonarr crate +//! +//! This module provides a factory for creating and caching sonarr Configuration +//! objects for communicating with Sonarr instances. + +use sonarr::apis::configuration::{ApiKey, Configuration}; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::RwLock; + +/// Factory for creating Sonarr API configurations +/// Caches configurations by instance name/namespace to reuse connections +pub struct SonarrClientFactory { + configs: RwLock>>, +} + +impl SonarrClientFactory { + pub fn new() -> Self { + Self { + configs: RwLock::new(HashMap::new()), + } + } + + /// Get or create a Configuration for the given Sonarr instance + pub async fn get_config( + &self, + url: &str, + api_key: &str, + instance_key: &str, + ) -> Arc { + // Check if we have a cached config + { + let configs = self.configs.read().await; + if let Some(config) = configs.get(instance_key) { + return config.clone(); + } + } + + // Create a new configuration + let mut config = Configuration::new(); + config.base_path = url.trim_end_matches('/').to_string(); + config.api_key = Some(ApiKey { + prefix: None, + key: api_key.to_string(), + }); + + let config = Arc::new(config); + + // Cache it + { + let mut configs = self.configs.write().await; + configs.insert(instance_key.to_string(), config.clone()); + } + + config + } + + /// Remove a configuration from the cache (e.g., when credentials change) + pub async fn invalidate(&self, instance_key: &str) { + let mut configs = self.configs.write().await; + configs.remove(instance_key); + } +} + +impl Default for SonarrClientFactory { + fn default() -> Self { + Self::new() + } +} + +// Re-export sonarr types that controllers will need +pub use sonarr::apis; +pub use sonarr::models; diff --git a/src/bin/crdgen.rs b/src/bin/crdgen.rs new file mode 100644 index 0000000..008bd54 --- /dev/null +++ b/src/bin/crdgen.rs @@ -0,0 +1,126 @@ +//! CRD Generator Binary +//! +//! This binary generates the Kubernetes Custom Resource Definitions (CRDs) for the Sonarr operator. +//! +//! Usage: +//! cargo run --bin crdgen # Output all CRDs to stdout (combined) +//! cargo run --bin crdgen -- --split # Write individual CRD files to directory +//! cargo run --bin crdgen -- --single # Output a single CRD to stdout +//! +//! Examples: +//! cargo run --bin crdgen > crds.yaml +//! cargo run --bin crdgen -- --split deploy/crds +//! cargo run --bin crdgen -- --single SonarrTag > tag-crd.yaml +//! +//! This outputs the CRDs as YAML which can be applied to a Kubernetes cluster: +//! kubectl apply -f deploy/crds/ + +use kube::CustomResourceExt; +use std::fs; +use std::path::Path; + +use sonarr_operator::crds; + +/// CRD definition with metadata for file generation +struct CrdInfo { + name: &'static str, + filename: &'static str, + crd: k8s_openapi::apiextensions_apiserver::pkg::apis::apiextensions::v1::CustomResourceDefinition, +} + +/// Macro to generate CRD entries from type names. +/// This ensures we can't forget to add new CRDs - just add the type name once. +macro_rules! register_crds { + ($($crd_type:ident => $filename:literal),* $(,)?) => { + vec![ + $( + CrdInfo { + name: stringify!($crd_type), + filename: $filename, + crd: crds::$crd_type::crd(), + }, + )* + ] + }; +} + +fn get_all_crds() -> Vec { + // To add a new CRD, simply add a new line here: TypeName => "filename.yaml" + register_crds!( + Sonarr => "sonarr.yaml", + SonarrAutoTag => "autotag.yaml", + SonarrCustomFormat => "customformat.yaml", + SonarrDelayProfile => "delayprofile.yaml", + SonarrDownloadClient => "downloadclient.yaml", + SonarrDownloadClientConfig => "downloadclientconfig.yaml", + SonarrImportList => "importlist.yaml", + SonarrIndexer => "indexer.yaml", + SonarrIndexerConfig => "indexerconfig.yaml", + SonarrLanguageProfile => "languageprofile.yaml", + SonarrMediaManagementConfig => "mediamanagementconfig.yaml", + SonarrMetadata => "metadata.yaml", + SonarrNamingConfig => "namingconfig.yaml", + SonarrNotification => "notification.yaml", + SonarrQualityDefinition => "qualitydefinition.yaml", + SonarrQualityProfile => "qualityprofile.yaml", + SonarrRootFolder => "rootfolder.yaml", + SonarrSeries => "series.yaml", + SonarrTag => "tag.yaml", + ) +} + +fn main() { + let args: Vec = std::env::args().collect(); + + // Check for --split flag (write individual files) + if args.len() > 2 && args[1] == "--split" { + let output_dir = &args[2]; + let path = Path::new(output_dir); + + // Create directory if it doesn't exist + if let Err(e) = fs::create_dir_all(path) { + eprintln!("Failed to create directory {}: {}", output_dir, e); + std::process::exit(1); + } + + let crds = get_all_crds(); + for crd_info in crds { + let file_path = path.join(crd_info.filename); + let yaml = serde_yaml::to_string(&crd_info.crd).unwrap(); + + if let Err(e) = fs::write(&file_path, yaml) { + eprintln!("Failed to write {}: {}", file_path.display(), e); + std::process::exit(1); + } + eprintln!("Generated: {}", file_path.display()); + } + + eprintln!("All CRDs generated in {}/", output_dir); + return; + } + + // Check if user wants a single CRD + if args.len() > 2 && args[1] == "--single" { + let crd_name = &args[2]; + let crds = get_all_crds(); + + if let Some(crd_info) = crds.iter().find(|c| c.name == crd_name) { + print!("{}", serde_yaml::to_string(&crd_info.crd).unwrap()); + return; + } + + eprintln!("Unknown CRD: {}", crd_name); + let available: Vec<_> = crds.iter().map(|c| c.name).collect(); + eprintln!("Available CRDs: {}", available.join(", ")); + std::process::exit(1); + } + + // Generate all CRDs to stdout (combined format) + let crds = get_all_crds(); + for (i, crd_info) in crds.iter().enumerate() { + if i > 0 { + println!("---"); + } + print!("{}", serde_yaml::to_string(&crd_info.crd).unwrap()); + } +} diff --git a/src/bin/generate_crd.rs b/src/bin/generate_crd.rs deleted file mode 100644 index 332bdcf..0000000 --- a/src/bin/generate_crd.rs +++ /dev/null @@ -1,35 +0,0 @@ -use kube::CustomResourceExt; -use k8s_opeartor_sonarr::crd::sonarr::Sonarr; -use std::fs; -use std::path::Path; - -use tracing::{debug, Level}; -use tracing_subscriber::{fmt, EnvFilter}; - - -fn main() -> Result<(), Box> { - tracing_subscriber::fmt::init(); - fmt() - .with_env_filter(EnvFilter::from_default_env().add_directive(Level::INFO.into())) - .with_writer(std::io::stdout) // Forza l'output su stdout - .init(); - debug!("Build CRD..."); - // Generate CRD definition - let crd = Sonarr::crd(); - - // Convert to YAML - let yaml = serde_yaml::to_string(&crd)?; - - // Create manifests directory if it doesn't exist - let path = Path::new("manifests/crds"); - if !path.exists() { - fs::create_dir(path)?; - } - - // Write to file - fs::write("manifests/crds/sonarr.yaml", yaml)?; - - debug!("Successfully generated CRD manifest at manifests/crds/sonarr.yaml"); - - Ok(()) -} \ No newline at end of file diff --git a/src/controller/mod.rs b/src/controller/mod.rs deleted file mode 100644 index faaef06..0000000 --- a/src/controller/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod reconciler; diff --git a/src/controller/reconciler.rs b/src/controller/reconciler.rs deleted file mode 100644 index d70f893..0000000 --- a/src/controller/reconciler.rs +++ /dev/null @@ -1,79 +0,0 @@ -use crate::crd::sonarr::Sonarr; -use crate::error::Error; -use chrono::Utc; -use k8s_openapi::apimachinery::pkg::apis::meta::v1::Time; -use kube::{ - api::{Api, Patch, PatchParams}, - client::Client, - runtime::controller::Action, - ResourceExt, -}; -use serde_json::json; -use std::{sync::Arc, time::Duration}; -use tracing::{error, info, warn}; - -/// State shared by the controller -pub struct SonarrState { - /// Kubernetes client - pub client: Client, -} - -impl SonarrState { - pub fn new(client: Client) -> Self { - SonarrState { client } - } -} - -/// Main reconciliation loop for the controller -pub async fn reconcile( - resource: Arc, - context: Arc, -) -> Result { - let client = context.client.clone(); - let name = resource.name_any(); - let namespace = resource.namespace().unwrap_or_else(|| "default".into()); - - info!("Reconciling Sonarr {} in {}", name, namespace); - - // Get API for Sonnar in the appropriate namespace - let api: Api = Api::namespaced(client, &namespace); - - // Initialize or update the status - let current_time = Utc::now(); - let status_patch = json!({ - "status": { - "observedGeneration": resource.metadata.generation, - "createdAt": current_time, - "conditions": [{ - "type": "Initialized", - "status": "True", - "lastTransitionTime": Time(current_time), - "reason": "ResourceCreated", - "message": "Resource has been initialized" - }], - "ready": true - } - }); - - // Apply the status update - let patch_params = PatchParams::apply("k8s-operator-sonarr").force(); - let _patched = api - .patch_status(&name, &patch_params, &Patch::Merge(&status_patch)) - .await - .map_err(|err| { - error!("Failed to patch status: {}", err); - Error::KubeError(err) - })?; - - info!("Successfully reconciled Sonnar {} in {}", name, namespace); - - // Requeue after 10 minutes by default - Ok(Action::requeue(Duration::from_secs(600))) -} - -/// Error policy for the controller -pub fn error_policy(resource: Arc, error: &Error, _ctx: Arc) -> Action { - warn!("Reconciliation error: {:?}", error); - // Retry after 5 minutes - Action::requeue(Duration::from_secs(300)) -} \ No newline at end of file diff --git a/src/controllers/auto_tag.rs b/src/controllers/auto_tag.rs new file mode 100644 index 0000000..8f1d17d --- /dev/null +++ b/src/controllers/auto_tag.rs @@ -0,0 +1,154 @@ +use std::sync::Arc; + +use kube::api::{Api, Patch, PatchParams}; +use kube::runtime::controller::Action; +use kube::{Client, ResourceExt}; +use tracing::info; + +use sonarr::apis::auto_tagging_api; +use sonarr::models::{AutoTaggingResource, AutoTaggingSpecificationSchema}; + +use crate::Context; +use crate::crds::auto_tag::{AutoTagImplementation, AutoTagSpecification}; +use crate::crds::{SonarrAutoTag, SonarrAutoTagStatus}; +use crate::error::{Error, Result}; + +use super::tag::get_sonarr_config; +use super::traits::{REQUEUE_DURATION, reconcile_with_finalizer, run_controller}; +use super::{ready_condition, update_conditions}; + +/// Start the SonarrAutoTag controller +pub async fn run(client: Client, context: Arc) { + run_controller::(client, context, "SonarrAutoTag", reconcile).await; +} + +async fn reconcile(obj: Arc, ctx: Arc) -> Result { + reconcile_with_finalizer(obj, ctx, reconcile_apply, reconcile_cleanup).await +} + +async fn reconcile_apply(auto_tag: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = auto_tag + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + let name = auto_tag.name_any(); + + info!("Reconciling SonarrAutoTag: {}/{}", namespace, name); + + let config = + get_sonarr_config(&ctx, client, &namespace, &auto_tag.spec.sonarr_instance_ref).await?; + + // Build auto-tagging resource + let mut resource = AutoTaggingResource::new(); + resource.name = Some(Some(auto_tag.spec.name.clone())); + resource.remove_tags_automatically = Some(auto_tag.spec.remove_tags_automatically); + resource.tags = Some(Some(auto_tag.spec.tags.clone())); + + // Convert specifications + let specs: Vec = auto_tag + .spec + .specifications + .iter() + .map(|spec| convert_specification(spec)) + .collect(); + resource.specifications = Some(Some(specs)); + + let sonarr_auto_tag = if let Some(id) = auto_tag.status.as_ref().and_then(|s| s.id) { + resource.id = Some(id); + match auto_tagging_api::update_auto_tagging( + &config, + &id.to_string(), + Some(resource.clone()), + ) + .await + { + Ok(a) => a, + Err(_) => { + resource.id = None; + auto_tagging_api::create_auto_tagging(&config, Some(resource)).await? + } + } + } else { + let existing = auto_tagging_api::list_auto_tagging(&config).await?; + if let Some(existing_item) = existing + .iter() + .find(|a| a.name.as_ref().and_then(|n| n.as_ref()) == Some(&auto_tag.spec.name)) + { + existing_item.clone() + } else { + auto_tagging_api::create_auto_tagging(&config, Some(resource)).await? + } + }; + + // Update status + let api: Api = Api::namespaced(client.clone(), &namespace); + let mut conditions = auto_tag + .status + .as_ref() + .map(|s| s.conditions.clone()) + .unwrap_or_default(); + update_conditions( + &mut conditions, + ready_condition(true, "Synced", "Auto tag synchronized with Sonarr"), + ); + + let status = SonarrAutoTagStatus { + conditions, + id: sonarr_auto_tag.id, + observed_generation: auto_tag.metadata.generation.unwrap_or(0), + }; + + let status_patch = serde_json::json!({ "status": status }); + api.patch_status(&name, &PatchParams::default(), &Patch::Merge(&status_patch)) + .await?; + + Ok(Action::requeue(REQUEUE_DURATION)) +} + +fn convert_specification(spec: &AutoTagSpecification) -> AutoTaggingSpecificationSchema { + let mut schema = AutoTaggingSpecificationSchema::new(); + schema.name = Some(Some(spec.name.clone())); + schema.implementation = Some(Some( + get_implementation_name(&spec.implementation).to_string(), + )); + schema.negate = Some(spec.negate); + schema.required = Some(spec.required); + // Fields would need more complex mapping + schema +} + +fn get_implementation_name(impl_type: &AutoTagImplementation) -> &'static str { + match impl_type { + AutoTagImplementation::RootFolderSpecification => "RootFolderSpecification", + AutoTagImplementation::GenreSpecification => "GenreSpecification", + AutoTagImplementation::YearSpecification => "YearSpecification", + AutoTagImplementation::SeriesTypeSpecification => "SeriesTypeSpecification", + AutoTagImplementation::QualityProfileSpecification => "QualityProfileSpecification", + AutoTagImplementation::NetworkSpecification => "NetworkSpecification", + AutoTagImplementation::OriginalLanguageSpecification => "OriginalLanguageSpecification", + AutoTagImplementation::TagSpecification => "TagSpecification", + } +} + +async fn reconcile_cleanup(auto_tag: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = auto_tag + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + + info!( + "Cleaning up SonarrAutoTag: {}/{}", + namespace, + auto_tag.name_any() + ); + + if let Some(id) = auto_tag.status.as_ref().and_then(|s| s.id) { + if let Ok(config) = + get_sonarr_config(&ctx, client, &namespace, &auto_tag.spec.sonarr_instance_ref).await + { + let _ = auto_tagging_api::delete_auto_tagging(&config, id).await; + } + } + + Ok(Action::await_change()) +} diff --git a/src/controllers/custom_format.rs b/src/controllers/custom_format.rs new file mode 100644 index 0000000..1cae81f --- /dev/null +++ b/src/controllers/custom_format.rs @@ -0,0 +1,155 @@ +use std::sync::Arc; + +use kube::api::{Api, Patch, PatchParams}; +use kube::runtime::controller::Action; +use kube::{Client, ResourceExt}; +use tracing::info; + +use sonarr::apis::custom_format_api; +use sonarr::models::{CustomFormatResource, CustomFormatSpecificationSchema}; + +use crate::Context; +use crate::crds::custom_format::{CustomFormatImplementation, CustomFormatSpecification}; +use crate::crds::{SonarrCustomFormat, SonarrCustomFormatStatus}; +use crate::error::{Error, Result}; + +use super::tag::get_sonarr_config; +use super::traits::{REQUEUE_DURATION, reconcile_with_finalizer, run_controller}; +use super::{ready_condition, update_conditions}; + +/// Start the SonarrCustomFormat controller +pub async fn run(client: Client, context: Arc) { + run_controller::(client, context, "SonarrCustomFormat", reconcile) + .await; +} + +async fn reconcile(obj: Arc, ctx: Arc) -> Result { + reconcile_with_finalizer(obj, ctx, reconcile_apply, reconcile_cleanup).await +} + +async fn reconcile_apply(cf: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = cf + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + let name = cf.name_any(); + + info!("Reconciling SonarrCustomFormat: {}/{}", namespace, name); + + let config = get_sonarr_config(&ctx, client, &namespace, &cf.spec.sonarr_instance_ref).await?; + + // Build custom format resource + let mut resource = CustomFormatResource::new(); + resource.name = Some(Some(cf.spec.name.clone())); + resource.include_custom_format_when_renaming = + Some(Some(cf.spec.include_custom_format_when_renaming)); + + // Convert specifications + let specs: Vec = cf + .spec + .specifications + .iter() + .map(|spec| convert_specification(spec)) + .collect(); + resource.specifications = Some(Some(specs)); + + let sonarr_cf = if let Some(id) = cf.status.as_ref().and_then(|s| s.id) { + resource.id = Some(id); + match custom_format_api::update_custom_format( + &config, + &id.to_string(), + Some(resource.clone()), + ) + .await + { + Ok(c) => c, + Err(_) => { + resource.id = None; + custom_format_api::create_custom_format(&config, Some(resource)).await? + } + } + } else { + let existing = custom_format_api::list_custom_format(&config).await?; + if let Some(existing_item) = existing + .iter() + .find(|c| c.name.as_ref().and_then(|n| n.as_ref()) == Some(&cf.spec.name)) + { + existing_item.clone() + } else { + custom_format_api::create_custom_format(&config, Some(resource)).await? + } + }; + + // Update status + let api: Api = Api::namespaced(client.clone(), &namespace); + let mut conditions = cf + .status + .as_ref() + .map(|s| s.conditions.clone()) + .unwrap_or_default(); + update_conditions( + &mut conditions, + ready_condition(true, "Synced", "Custom format synchronized with Sonarr"), + ); + + let status = SonarrCustomFormatStatus { + conditions, + id: sonarr_cf.id, + observed_generation: cf.metadata.generation.unwrap_or(0), + }; + + let status_patch = serde_json::json!({ "status": status }); + api.patch_status(&name, &PatchParams::default(), &Patch::Merge(&status_patch)) + .await?; + + Ok(Action::requeue(REQUEUE_DURATION)) +} + +fn convert_specification(spec: &CustomFormatSpecification) -> CustomFormatSpecificationSchema { + let mut schema = CustomFormatSpecificationSchema::new(); + schema.name = Some(Some(spec.name.clone())); + schema.implementation = Some(Some( + get_implementation_name(&spec.implementation).to_string(), + )); + schema.negate = Some(spec.negate); + schema.required = Some(spec.required); + // Fields would need more complex mapping + schema +} + +fn get_implementation_name(impl_type: &CustomFormatImplementation) -> &'static str { + match impl_type { + CustomFormatImplementation::ReleaseTitleSpecification => "ReleaseTitleSpecification", + CustomFormatImplementation::SourceSpecification => "SourceSpecification", + CustomFormatImplementation::ResolutionSpecification => "ResolutionSpecification", + CustomFormatImplementation::QualityModifierSpecification => "QualityModifierSpecification", + CustomFormatImplementation::SizeSpecification => "SizeSpecification", + CustomFormatImplementation::IndexerFlagSpecification => "IndexerFlagSpecification", + CustomFormatImplementation::LanguageSpecification => "LanguageSpecification", + CustomFormatImplementation::ReleaseGroupSpecification => "ReleaseGroupSpecification", + CustomFormatImplementation::EditionSpecification => "EditionSpecification", + } +} + +async fn reconcile_cleanup(cf: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = cf + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + + info!( + "Cleaning up SonarrCustomFormat: {}/{}", + namespace, + cf.name_any() + ); + + if let Some(id) = cf.status.as_ref().and_then(|s| s.id) { + if let Ok(config) = + get_sonarr_config(&ctx, client, &namespace, &cf.spec.sonarr_instance_ref).await + { + let _ = custom_format_api::delete_custom_format(&config, id).await; + } + } + + Ok(Action::await_change()) +} diff --git a/src/controllers/delay_profile.rs b/src/controllers/delay_profile.rs new file mode 100644 index 0000000..756317c --- /dev/null +++ b/src/controllers/delay_profile.rs @@ -0,0 +1,108 @@ +use std::sync::Arc; + +use kube::api::{Api, Patch, PatchParams}; +use kube::runtime::controller::Action; +use kube::{Client, ResourceExt}; +use tracing::info; + +use sonarr::apis::delay_profile_api; +use sonarr::models::DelayProfileResource; + +use crate::crds::{SonarrDelayProfile, SonarrDelayProfileStatus}; +use crate::crds::delay_profile::DownloadProtocol; +use crate::error::{Error, Result}; +use crate::Context; + +use super::traits::{run_controller, reconcile_with_finalizer, REQUEUE_DURATION}; +use super::tag::get_sonarr_config; +use super::{ready_condition, update_conditions}; + +/// Start the SonarrDelayProfile controller +pub async fn run(client: Client, context: Arc) { + run_controller::( + client, + context, + "SonarrDelayProfile", + reconcile, + ).await; +} + +async fn reconcile(obj: Arc, ctx: Arc) -> Result { + reconcile_with_finalizer(obj, ctx, reconcile_apply, reconcile_cleanup).await +} + +async fn reconcile_apply(profile: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = profile.namespace().ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + let name = profile.name_any(); + + info!("Reconciling SonarrDelayProfile: {}/{}", namespace, name); + + let config = get_sonarr_config(&ctx, client, &namespace, &profile.spec.sonarr_instance_ref).await?; + + // Build delay profile resource + let mut resource = DelayProfileResource::new(); + resource.enable_usenet = Some(profile.spec.enable_usenet); + resource.enable_torrent = Some(profile.spec.enable_torrent); + resource.preferred_protocol = Some(convert_protocol(&profile.spec.preferred_protocol)); + resource.usenet_delay = Some(profile.spec.usenet_delay); + resource.torrent_delay = Some(profile.spec.torrent_delay); + resource.bypass_if_highest_quality = Some(profile.spec.bypass_if_highest_quality); + resource.bypass_if_above_custom_format_score = Some(profile.spec.bypass_if_above_custom_format_score); + resource.minimum_custom_format_score = Some(profile.spec.minimum_custom_format_score); + resource.order = Some(profile.spec.order); + resource.tags = Some(Some(profile.spec.tags.clone())); + + let sonarr_profile = if let Some(id) = profile.status.as_ref().and_then(|s| s.id) { + resource.id = Some(id); + match delay_profile_api::update_delay_profile(&config, &id.to_string(), Some(resource.clone())).await { + Ok(p) => p, + Err(_) => { + resource.id = None; + delay_profile_api::create_delay_profile(&config, Some(resource)).await? + } + } + } else { + // Delay profiles don't have unique names, so we always create new ones + // unless we have an ID stored + delay_profile_api::create_delay_profile(&config, Some(resource)).await? + }; + + // Update status + let api: Api = Api::namespaced(client.clone(), &namespace); + let mut conditions = profile.status.as_ref().map(|s| s.conditions.clone()).unwrap_or_default(); + update_conditions(&mut conditions, ready_condition(true, "Synced", "Delay profile synchronized with Sonarr")); + + let status = SonarrDelayProfileStatus { + conditions, + id: sonarr_profile.id, + observed_generation: profile.metadata.generation.unwrap_or(0), + }; + + let status_patch = serde_json::json!({ "status": status }); + api.patch_status(&name, &PatchParams::default(), &Patch::Merge(&status_patch)).await?; + + Ok(Action::requeue(REQUEUE_DURATION)) +} + +fn convert_protocol(protocol: &DownloadProtocol) -> sonarr::models::DownloadProtocol { + match protocol { + DownloadProtocol::Usenet => sonarr::models::DownloadProtocol::Usenet, + DownloadProtocol::Torrent => sonarr::models::DownloadProtocol::Torrent, + } +} + +async fn reconcile_cleanup(profile: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = profile.namespace().ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + + info!("Cleaning up SonarrDelayProfile: {}/{}", namespace, profile.name_any()); + + if let Some(id) = profile.status.as_ref().and_then(|s| s.id) { + if let Ok(config) = get_sonarr_config(&ctx, client, &namespace, &profile.spec.sonarr_instance_ref).await { + let _ = delay_profile_api::delete_delay_profile(&config, id).await; + } + } + + Ok(Action::await_change()) +} diff --git a/src/controllers/download_client.rs b/src/controllers/download_client.rs new file mode 100644 index 0000000..ab9819e --- /dev/null +++ b/src/controllers/download_client.rs @@ -0,0 +1,123 @@ +use std::sync::Arc; + +use kube::api::{Api, Patch, PatchParams}; +use kube::runtime::controller::Action; +use kube::{Client, ResourceExt}; +use tracing::info; + +use sonarr::apis::download_client_api; +use sonarr::models::DownloadClientResource; + +use crate::crds::{SonarrDownloadClient, SonarrDownloadClientStatus}; +use crate::crds::download_client::DownloadClientType; +use crate::error::{Error, Result}; +use crate::Context; + +use super::traits::{run_controller, reconcile_with_finalizer, REQUEUE_DURATION}; +use super::tag::get_sonarr_config; +use super::{ready_condition, update_conditions}; + +/// Start the SonarrDownloadClient controller +pub async fn run(client: Client, context: Arc) { + run_controller::( + client, + context, + "SonarrDownloadClient", + reconcile, + ).await; +} + +async fn reconcile(obj: Arc, ctx: Arc) -> Result { + reconcile_with_finalizer(obj, ctx, reconcile_apply, reconcile_cleanup).await +} + +async fn reconcile_apply(dc: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = dc.namespace().ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + let name = dc.name_any(); + + info!("Reconciling SonarrDownloadClient: {}/{}", namespace, name); + + let config = get_sonarr_config(&ctx, client, &namespace, &dc.spec.sonarr_instance_ref).await?; + + // Build download client resource + let mut dc_resource = DownloadClientResource::new(); + dc_resource.name = Some(Some(dc.spec.name.clone())); + dc_resource.implementation = Some(Some(get_implementation_name(&dc.spec.download_client_type).to_string())); + dc_resource.enable = Some(dc.spec.enable); + dc_resource.priority = Some(dc.spec.priority); + dc_resource.remove_completed_downloads = Some(dc.spec.remove_completed_downloads); + dc_resource.remove_failed_downloads = Some(dc.spec.remove_failed_downloads); + dc_resource.tags = Some(Some(dc.spec.tags.clone())); + + let sonarr_dc = if let Some(id) = dc.status.as_ref().and_then(|s| s.id) { + dc_resource.id = Some(id); + match download_client_api::update_download_client(&config, id, Some(false), Some(dc_resource.clone())).await { + Ok(d) => d, + Err(_) => { + dc_resource.id = None; + download_client_api::create_download_client(&config, Some(false), Some(dc_resource)).await? + } + } + } else { + let existing = download_client_api::list_download_client(&config).await?; + if let Some(existing_dc) = existing.iter().find(|d| d.name.as_ref().and_then(|n| n.as_ref()) == Some(&dc.spec.name)) { + existing_dc.clone() + } else { + download_client_api::create_download_client(&config, Some(false), Some(dc_resource)).await? + } + }; + + // Update status + let clients_api: Api = Api::namespaced(client.clone(), &namespace); + let mut conditions = dc.status.as_ref().map(|s| s.conditions.clone()).unwrap_or_default(); + update_conditions(&mut conditions, ready_condition(true, "Synced", "Download client synchronized with Sonarr")); + + let status = SonarrDownloadClientStatus { + conditions, + id: sonarr_dc.id, + observed_generation: dc.metadata.generation.unwrap_or(0), + }; + + let status_patch = serde_json::json!({ "status": status }); + clients_api.patch_status(&name, &PatchParams::default(), &Patch::Merge(&status_patch)).await?; + + Ok(Action::requeue(REQUEUE_DURATION)) +} + +fn get_implementation_name(dc_type: &DownloadClientType) -> &'static str { + match dc_type { + DownloadClientType::Aria2 => "Aria2", + DownloadClientType::Deluge => "Deluge", + DownloadClientType::Flood => "Flood", + DownloadClientType::Hadouken => "Hadouken", + DownloadClientType::NzbGet => "Nzbget", + DownloadClientType::NzbVortex => "NzbVortex", + DownloadClientType::Pneumatic => "Pneumatic", + DownloadClientType::QBittorrent => "QBittorrent", + DownloadClientType::RTorrent => "RTorrent", + DownloadClientType::SABnzbd => "Sabnzbd", + DownloadClientType::TorrentBlackhole => "TorrentBlackhole", + DownloadClientType::TorrentDownloadStation => "TorrentDownloadStation", + DownloadClientType::Transmission => "Transmission", + DownloadClientType::UsenetBlackhole => "UsenetBlackhole", + DownloadClientType::UsenetDownloadStation => "UsenetDownloadStation", + DownloadClientType::UTorrent => "UTorrent", + DownloadClientType::Vuze => "Vuze", + } +} + +async fn reconcile_cleanup(dc: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = dc.namespace().ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + + info!("Cleaning up SonarrDownloadClient: {}/{}", namespace, dc.name_any()); + + if let Some(id) = dc.status.as_ref().and_then(|s| s.id) { + if let Ok(config) = get_sonarr_config(&ctx, client, &namespace, &dc.spec.sonarr_instance_ref).await { + let _ = download_client_api::delete_download_client(&config, id).await; + } + } + + Ok(Action::await_change()) +} diff --git a/src/controllers/download_client_config.rs b/src/controllers/download_client_config.rs new file mode 100644 index 0000000..8742932 --- /dev/null +++ b/src/controllers/download_client_config.rs @@ -0,0 +1,213 @@ +//! Controller for SonarrDownloadClientConfig +//! +//! This controller manages global download client configuration for Sonarr instances. +//! Only one SonarrDownloadClientConfig per Sonarr instance is allowed. + +use std::sync::Arc; + +use kube::api::{Api, ListParams, Patch, PatchParams}; +use kube::runtime::controller::Action; +use kube::{Client, ResourceExt}; +use tracing::{info, warn}; + +use sonarr::apis::download_client_config_api; +use sonarr::models::DownloadClientConfigResource; + +use crate::Context; +use crate::crds::download_client_config::{ + SonarrDownloadClientConfig, SonarrDownloadClientConfigStatus, +}; +use crate::error::{Error, Result}; + +use super::tag::get_sonarr_config; +use super::traits::{REQUEUE_DURATION, reconcile_with_finalizer, run_controller}; +use super::{ready_condition, update_conditions}; + +/// Start the SonarrDownloadClientConfig controller +pub async fn run(client: Client, context: Arc) { + run_controller::( + client, + context, + "SonarrDownloadClientConfig", + reconcile, + ) + .await; +} + +async fn reconcile(obj: Arc, ctx: Arc) -> Result { + reconcile_with_finalizer(obj, ctx, reconcile_apply, reconcile_cleanup).await +} + +/// Check if another SonarrDownloadClientConfig exists for the same Sonarr instance +async fn check_singleton( + client: &Client, + namespace: &str, + current_name: &str, + instance_ref_name: &str, + instance_ref_namespace: Option<&str>, +) -> Result> { + let api: Api = Api::namespaced(client.clone(), namespace); + let configs = api.list(&ListParams::default()).await?; + + for config in configs.items { + let config_name = config.name_any(); + if config_name == current_name { + continue; + } + + let ref_name = &config.spec.sonarr_instance_ref.name; + let ref_ns = config.spec.sonarr_instance_ref.namespace.as_deref(); + + if ref_name == instance_ref_name && ref_ns == instance_ref_namespace { + return Ok(Some(config_name)); + } + } + + Ok(None) +} + +async fn reconcile_apply( + config: Arc, + ctx: Arc, +) -> Result { + let client = &ctx.client; + let namespace = config + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + let name = config.name_any(); + + info!( + "Reconciling SonarrDownloadClientConfig: {}/{}", + namespace, name + ); + + // Check singleton constraint + if let Some(existing_name) = check_singleton( + client, + &namespace, + &name, + &config.spec.sonarr_instance_ref.name, + config.spec.sonarr_instance_ref.namespace.as_deref(), + ) + .await? + { + warn!( + "Another SonarrDownloadClientConfig '{}' already exists for Sonarr instance '{}'. Only one config per instance is allowed.", + existing_name, config.spec.sonarr_instance_ref.name + ); + + let api: Api = Api::namespaced(client.clone(), &namespace); + let mut conditions = config + .status + .as_ref() + .map(|s| s.conditions.clone()) + .unwrap_or_default(); + update_conditions( + &mut conditions, + ready_condition( + false, + "Conflict", + &format!( + "Another config '{}' already exists for this Sonarr instance", + existing_name + ), + ), + ); + + let status = SonarrDownloadClientConfigStatus { + conditions, + observed_generation: config.metadata.generation.unwrap_or(0), + }; + + api.patch_status( + &name, + &PatchParams::apply("sonarr-operator"), + &Patch::Merge(serde_json::json!({ "status": status })), + ) + .await?; + + return Ok(Action::requeue(REQUEUE_DURATION)); + } + + let sonarr_config = + get_sonarr_config(&ctx, client, &namespace, &config.spec.sonarr_instance_ref).await?; + + // Get existing config (there's only one, id=1) + let existing = + download_client_config_api::get_download_client_config(&sonarr_config).await?; + + // Build update resource + let mut resource = DownloadClientConfigResource::new(); + resource.id = existing.id; + + resource.download_client_working_folders = config + .spec + .download_client_working_folders + .clone() + .map(Some) + .or(existing.download_client_working_folders); + + resource.enable_completed_download_handling = config + .spec + .enable_completed_download_handling + .or(existing.enable_completed_download_handling); + + resource.auto_redownload_failed = config + .spec + .auto_redownload_failed + .or(existing.auto_redownload_failed); + + resource.auto_redownload_failed_from_interactive_search = config + .spec + .auto_redownload_failed_from_interactive_search + .or(existing.auto_redownload_failed_from_interactive_search); + + // Update config + let id = existing + .id + .ok_or(Error::MissingObjectKey("download_client_config.id"))?; + download_client_config_api::update_download_client_config( + &sonarr_config, + &id.to_string(), + Some(resource), + ) + .await?; + + // Update status + let api: Api = Api::namespaced(client.clone(), &namespace); + let mut conditions = config + .status + .as_ref() + .map(|s| s.conditions.clone()) + .unwrap_or_default(); + update_conditions( + &mut conditions, + ready_condition( + true, + "Synced", + "Download client config synchronized with Sonarr", + ), + ); + + let status = SonarrDownloadClientConfigStatus { + conditions, + observed_generation: config.metadata.generation.unwrap_or(0), + }; + + api.patch_status( + &name, + &PatchParams::apply("sonarr-operator"), + &Patch::Merge(serde_json::json!({ "status": status })), + ) + .await?; + + Ok(Action::requeue(REQUEUE_DURATION)) +} + +async fn reconcile_cleanup( + _config: Arc, + _ctx: Arc, +) -> Result { + // Config settings persist in Sonarr, nothing to clean up + Ok(Action::await_change()) +} diff --git a/src/controllers/import_list.rs b/src/controllers/import_list.rs new file mode 100644 index 0000000..75ee8e3 --- /dev/null +++ b/src/controllers/import_list.rs @@ -0,0 +1,151 @@ +use std::sync::Arc; + +use kube::api::{Api, Patch, PatchParams}; +use kube::runtime::controller::Action; +use kube::{Client, ResourceExt}; +use tracing::info; + +use sonarr::apis::import_list_api; +use sonarr::models::ImportListResource; + +use crate::crds::{SonarrImportList, SonarrImportListStatus}; +use crate::crds::import_list::{ImportListType, MonitorTypes, NewItemMonitorTypes, SeriesTypes}; +use crate::error::{Error, Result}; +use crate::Context; + +use super::traits::{run_controller, reconcile_with_finalizer, REQUEUE_DURATION}; +use super::tag::get_sonarr_config; +use super::{ready_condition, update_conditions}; + +/// Start the SonarrImportList controller +pub async fn run(client: Client, context: Arc) { + run_controller::( + client, + context, + "SonarrImportList", + reconcile, + ).await; +} + +async fn reconcile(obj: Arc, ctx: Arc) -> Result { + reconcile_with_finalizer(obj, ctx, reconcile_apply, reconcile_cleanup).await +} + +async fn reconcile_apply(import_list: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = import_list.namespace().ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + let name = import_list.name_any(); + + info!("Reconciling SonarrImportList: {}/{}", namespace, name); + + let config = get_sonarr_config(&ctx, client, &namespace, &import_list.spec.sonarr_instance_ref).await?; + + // Build import list resource + let mut resource = ImportListResource::new(); + resource.name = Some(Some(import_list.spec.name.clone())); + resource.implementation = Some(Some(get_implementation_name(&import_list.spec.list_type).to_string())); + resource.enable_automatic_add = Some(import_list.spec.enable_automatic_add); + resource.search_for_missing_episodes = Some(import_list.spec.search_for_missing_episodes); + resource.should_monitor = Some(convert_monitor_type(&import_list.spec.should_monitor)); + resource.monitor_new_items = Some(convert_new_item_monitor_type(&import_list.spec.monitor_new_items)); + resource.root_folder_path = Some(Some(import_list.spec.root_folder_path.clone())); + resource.quality_profile_id = Some(import_list.spec.quality_profile_id); + resource.series_type = Some(convert_series_type(&import_list.spec.series_type)); + resource.season_folder = Some(import_list.spec.season_folder); + resource.list_order = Some(import_list.spec.list_order); + resource.tags = Some(Some(import_list.spec.tags.clone())); + + let sonarr_import_list = if let Some(id) = import_list.status.as_ref().and_then(|s| s.id) { + resource.id = Some(id); + match import_list_api::update_import_list(&config, id, Some(false), Some(resource.clone())).await { + Ok(i) => i, + Err(_) => { + resource.id = None; + import_list_api::create_import_list(&config, Some(false), Some(resource)).await? + } + } + } else { + let existing = import_list_api::list_import_list(&config).await?; + if let Some(existing_item) = existing.iter().find(|i| i.name.as_ref().and_then(|n| n.as_ref()) == Some(&import_list.spec.name)) { + existing_item.clone() + } else { + import_list_api::create_import_list(&config, Some(false), Some(resource)).await? + } + }; + + // Update status + let api: Api = Api::namespaced(client.clone(), &namespace); + let mut conditions = import_list.status.as_ref().map(|s| s.conditions.clone()).unwrap_or_default(); + update_conditions(&mut conditions, ready_condition(true, "Synced", "Import list synchronized with Sonarr")); + + let status = SonarrImportListStatus { + conditions, + id: sonarr_import_list.id, + observed_generation: import_list.metadata.generation.unwrap_or(0), + }; + + let status_patch = serde_json::json!({ "status": status }); + api.patch_status(&name, &PatchParams::default(), &Patch::Merge(&status_patch)).await?; + + Ok(Action::requeue(REQUEUE_DURATION)) +} + +fn get_implementation_name(list_type: &ImportListType) -> &'static str { + match list_type { + ImportListType::SonarrImport => "SonarrImport", + ImportListType::TraktListImport => "TraktListImport", + ImportListType::TraktUserImport => "TraktUserImport", + ImportListType::TraktPopularImport => "TraktPopularImport", + ImportListType::PlexImport => "PlexImport", + ImportListType::ImdbListImport => "ImdbListImport", + ImportListType::CustomImport => "CustomImport", + ImportListType::SimklImport => "SimklImport", + ImportListType::AniListImport => "AniListImport", + ImportListType::MyAnimeListImport => "MyAnimeListImport", + } +} + +fn convert_monitor_type(mt: &MonitorTypes) -> sonarr::models::MonitorTypes { + match mt { + MonitorTypes::All => sonarr::models::MonitorTypes::All, + MonitorTypes::Future => sonarr::models::MonitorTypes::Future, + MonitorTypes::Missing => sonarr::models::MonitorTypes::Missing, + MonitorTypes::Existing => sonarr::models::MonitorTypes::Existing, + MonitorTypes::FirstSeason => sonarr::models::MonitorTypes::FirstSeason, + MonitorTypes::LatestSeason => sonarr::models::MonitorTypes::LatestSeason, + MonitorTypes::Pilot => sonarr::models::MonitorTypes::Pilot, + MonitorTypes::MonitorSpecials => sonarr::models::MonitorTypes::MonitorSpecials, + MonitorTypes::UnmonitorSpecials => sonarr::models::MonitorTypes::UnmonitorSpecials, + MonitorTypes::None => sonarr::models::MonitorTypes::None, + } +} + +fn convert_new_item_monitor_type(mt: &NewItemMonitorTypes) -> sonarr::models::NewItemMonitorTypes { + match mt { + NewItemMonitorTypes::All => sonarr::models::NewItemMonitorTypes::All, + NewItemMonitorTypes::None => sonarr::models::NewItemMonitorTypes::None, + } +} + +fn convert_series_type(st: &SeriesTypes) -> sonarr::models::SeriesTypes { + match st { + SeriesTypes::Standard => sonarr::models::SeriesTypes::Standard, + SeriesTypes::Daily => sonarr::models::SeriesTypes::Daily, + SeriesTypes::Anime => sonarr::models::SeriesTypes::Anime, + } +} + +async fn reconcile_cleanup(import_list: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = import_list.namespace().ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + + info!("Cleaning up SonarrImportList: {}/{}", namespace, import_list.name_any()); + + if let Some(id) = import_list.status.as_ref().and_then(|s| s.id) { + if let Ok(config) = get_sonarr_config(&ctx, client, &namespace, &import_list.spec.sonarr_instance_ref).await { + let _ = import_list_api::delete_import_list(&config, id).await; + } + } + + Ok(Action::await_change()) +} diff --git a/src/controllers/indexer.rs b/src/controllers/indexer.rs new file mode 100644 index 0000000..5a35a7c --- /dev/null +++ b/src/controllers/indexer.rs @@ -0,0 +1,155 @@ +use std::sync::Arc; + +use k8s_openapi::api::core::v1::Secret; +use kube::api::{Api, Patch, PatchParams}; +use kube::runtime::controller::Action; +use kube::{Client, ResourceExt}; +use tracing::info; + +use sonarr::apis::indexer_api; +use sonarr::models::IndexerResource; + +use crate::crds::{SonarrIndexer, SonarrIndexerStatus}; +use crate::crds::indexer::IndexerType; +use crate::error::{Error, Result}; +use crate::Context; + +use super::traits::{run_controller, reconcile_with_finalizer, REQUEUE_DURATION}; +use super::tag::get_sonarr_config; +use super::{ready_condition, update_conditions}; + +/// Start the SonarrIndexer controller +pub async fn run(client: Client, context: Arc) { + run_controller::( + client, + context, + "SonarrIndexer", + reconcile, + ).await; +} + +async fn reconcile(obj: Arc, ctx: Arc) -> Result { + reconcile_with_finalizer(obj, ctx, reconcile_apply, reconcile_cleanup).await +} + +async fn reconcile_apply(indexer: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = indexer.namespace().ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + let name = indexer.name_any(); + + info!("Reconciling SonarrIndexer: {}/{}", namespace, name); + + let config = get_sonarr_config(&ctx, client, &namespace, &indexer.spec.sonarr_instance_ref).await?; + + // Resolve API key from secret if needed + // TODO: Use the api_key in indexer fields/config when setting up the indexer resource + let _api_key = resolve_secret_value( + client, + &namespace, + indexer.spec.config.api_key.clone(), + indexer.spec.config.api_key_secret_ref.as_ref() + ).await?; + + // Build indexer resource + let mut idx_resource = IndexerResource::new(); + idx_resource.name = Some(Some(indexer.spec.name.clone())); + idx_resource.implementation = Some(Some(get_implementation_name(&indexer.spec.indexer_type).to_string())); + idx_resource.enable_rss = Some(indexer.spec.enable_rss); + idx_resource.enable_automatic_search = Some(indexer.spec.enable_automatic_search); + idx_resource.enable_interactive_search = Some(indexer.spec.enable_interactive_search); + idx_resource.priority = Some(indexer.spec.priority); + idx_resource.tags = Some(Some(indexer.spec.tags.clone())); + + // Fields would need more complex mapping for the sonarr crate + + let sonarr_indexer = if let Some(id) = indexer.status.as_ref().and_then(|s| s.id) { + idx_resource.id = Some(id); + match indexer_api::update_indexer(&config, id, Some(false), Some(idx_resource.clone())).await { + Ok(i) => i, + Err(_) => { + idx_resource.id = None; + indexer_api::create_indexer(&config, Some(false), Some(idx_resource)).await? + } + } + } else { + let existing = indexer_api::list_indexer(&config).await?; + if let Some(existing_idx) = existing.iter().find(|i| i.name.as_ref().and_then(|n| n.as_ref()) == Some(&indexer.spec.name)) { + existing_idx.clone() + } else { + indexer_api::create_indexer(&config, Some(false), Some(idx_resource)).await? + } + }; + + // Update status + let indexers_api: Api = Api::namespaced(client.clone(), &namespace); + let mut conditions = indexer.status.as_ref().map(|s| s.conditions.clone()).unwrap_or_default(); + update_conditions(&mut conditions, ready_condition(true, "Synced", "Indexer synchronized with Sonarr")); + + let status = SonarrIndexerStatus { + conditions, + id: sonarr_indexer.id, + observed_generation: indexer.metadata.generation.unwrap_or(0), + }; + + let status_patch = serde_json::json!({ "status": status }); + indexers_api.patch_status(&name, &PatchParams::default(), &Patch::Merge(&status_patch)).await?; + + Ok(Action::requeue(REQUEUE_DURATION)) +} + +fn get_implementation_name(indexer_type: &IndexerType) -> &'static str { + match indexer_type { + IndexerType::Newznab => "Newznab", + IndexerType::Torznab => "Torznab", + IndexerType::Fanzub => "Fanzub", + IndexerType::BroadcastheNet => "BroadcastheNet", + IndexerType::FileList => "FileList", + IndexerType::HDBits => "HDBits", + IndexerType::IPTorrents => "IPTorrents", + IndexerType::Nyaa => "Nyaa", + IndexerType::TorrentRss => "TorrentRssIndexer", + IndexerType::TorrentLeech => "TorrentLeech", + } +} + +pub async fn resolve_secret_value( + client: &Client, + namespace: &str, + direct_value: Option, + secret_ref: Option<&crate::crds::SecretKeySelector>, +) -> Result> { + if let Some(value) = direct_value { + return Ok(Some(value)); + } + + if let Some(ref secret_ref) = secret_ref { + let secrets: Api = Api::namespaced(client.clone(), namespace); + let secret = secrets.get(&secret_ref.name).await + .map_err(|_| Error::MissingApiCredentials)?; + + let data = secret.data.ok_or(Error::MissingApiCredentials)?; + let value_bytes = data.get(&secret_ref.key).ok_or(Error::MissingApiCredentials)?; + + let value = String::from_utf8(value_bytes.0.clone()) + .map_err(|_| Error::MissingApiCredentials)?; + + return Ok(Some(value)); + } + + Ok(None) +} + +async fn reconcile_cleanup(indexer: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = indexer.namespace().ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + + info!("Cleaning up SonarrIndexer: {}/{}", namespace, indexer.name_any()); + + if let Some(id) = indexer.status.as_ref().and_then(|s| s.id) { + if let Ok(config) = get_sonarr_config(&ctx, client, &namespace, &indexer.spec.sonarr_instance_ref).await { + let _ = indexer_api::delete_indexer(&config, id).await; + } + } + + Ok(Action::await_change()) +} diff --git a/src/controllers/indexer_config.rs b/src/controllers/indexer_config.rs new file mode 100644 index 0000000..2d6f3bd --- /dev/null +++ b/src/controllers/indexer_config.rs @@ -0,0 +1,179 @@ +//! Controller for SonarrIndexerConfig +//! +//! This controller manages global indexer configuration for Sonarr instances. +//! Only one SonarrIndexerConfig per Sonarr instance is allowed. + +use std::sync::Arc; + +use kube::api::{Api, ListParams, Patch, PatchParams}; +use kube::runtime::controller::Action; +use kube::{Client, ResourceExt}; +use tracing::{info, warn}; + +use sonarr::apis::indexer_config_api; +use sonarr::models::IndexerConfigResource; + +use crate::Context; +use crate::crds::indexer_config::{SonarrIndexerConfig, SonarrIndexerConfigStatus}; +use crate::error::{Error, Result}; + +use super::tag::get_sonarr_config; +use super::traits::{REQUEUE_DURATION, reconcile_with_finalizer, run_controller}; +use super::{ready_condition, update_conditions}; + +/// Start the SonarrIndexerConfig controller +pub async fn run(client: Client, context: Arc) { + run_controller::( + client, + context, + "SonarrIndexerConfig", + reconcile, + ) + .await; +} + +async fn reconcile(obj: Arc, ctx: Arc) -> Result { + reconcile_with_finalizer(obj, ctx, reconcile_apply, reconcile_cleanup).await +} + +/// Check if another SonarrIndexerConfig exists for the same Sonarr instance +async fn check_singleton( + client: &Client, + namespace: &str, + current_name: &str, + instance_ref_name: &str, + instance_ref_namespace: Option<&str>, +) -> Result> { + let api: Api = Api::namespaced(client.clone(), namespace); + let configs = api.list(&ListParams::default()).await?; + + for config in configs.items { + let config_name = config.name_any(); + if config_name == current_name { + continue; + } + + let ref_name = &config.spec.sonarr_instance_ref.name; + let ref_ns = config.spec.sonarr_instance_ref.namespace.as_deref(); + + if ref_name == instance_ref_name && ref_ns == instance_ref_namespace { + return Ok(Some(config_name)); + } + } + + Ok(None) +} + +async fn reconcile_apply(config: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = config + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + let name = config.name_any(); + + info!("Reconciling SonarrIndexerConfig: {}/{}", namespace, name); + + // Check singleton constraint + if let Some(existing_name) = check_singleton( + client, + &namespace, + &name, + &config.spec.sonarr_instance_ref.name, + config.spec.sonarr_instance_ref.namespace.as_deref(), + ) + .await? + { + warn!( + "Another SonarrIndexerConfig '{}' already exists for Sonarr instance '{}'. Only one config per instance is allowed.", + existing_name, config.spec.sonarr_instance_ref.name + ); + + let api: Api = Api::namespaced(client.clone(), &namespace); + let mut conditions = config + .status + .as_ref() + .map(|s| s.conditions.clone()) + .unwrap_or_default(); + update_conditions( + &mut conditions, + ready_condition( + false, + "Conflict", + &format!( + "Another config '{}' already exists for this Sonarr instance", + existing_name + ), + ), + ); + + let status = SonarrIndexerConfigStatus { + conditions, + observed_generation: config.metadata.generation.unwrap_or(0), + }; + + api.patch_status( + &name, + &PatchParams::apply("sonarr-operator"), + &Patch::Merge(serde_json::json!({ "status": status })), + ) + .await?; + + return Ok(Action::requeue(REQUEUE_DURATION)); + } + + let sonarr_config = + get_sonarr_config(&ctx, client, &namespace, &config.spec.sonarr_instance_ref).await?; + + // Get existing config (there's only one, id=1) + let existing = indexer_config_api::get_indexer_config(&sonarr_config).await?; + + // Build update resource + let mut resource = IndexerConfigResource::new(); + resource.id = existing.id; + + resource.minimum_age = config.spec.minimum_age.or(existing.minimum_age); + resource.retention = config.spec.retention.or(existing.retention); + resource.maximum_size = config.spec.maximum_size.or(existing.maximum_size); + resource.rss_sync_interval = config.spec.rss_sync_interval.or(existing.rss_sync_interval); + + // Update config + let id = existing + .id + .ok_or(Error::MissingObjectKey("indexer_config.id"))?; + indexer_config_api::update_indexer_config(&sonarr_config, &id.to_string(), Some(resource)) + .await?; + + // Update status + let api: Api = Api::namespaced(client.clone(), &namespace); + let mut conditions = config + .status + .as_ref() + .map(|s| s.conditions.clone()) + .unwrap_or_default(); + update_conditions( + &mut conditions, + ready_condition(true, "Synced", "Indexer config synchronized with Sonarr"), + ); + + let status = SonarrIndexerConfigStatus { + conditions, + observed_generation: config.metadata.generation.unwrap_or(0), + }; + + api.patch_status( + &name, + &PatchParams::apply("sonarr-operator"), + &Patch::Merge(serde_json::json!({ "status": status })), + ) + .await?; + + Ok(Action::requeue(REQUEUE_DURATION)) +} + +async fn reconcile_cleanup( + _config: Arc, + _ctx: Arc, +) -> Result { + // Config settings persist in Sonarr, nothing to clean up + Ok(Action::await_change()) +} diff --git a/src/controllers/language_profile.rs b/src/controllers/language_profile.rs new file mode 100644 index 0000000..12399ed --- /dev/null +++ b/src/controllers/language_profile.rs @@ -0,0 +1,206 @@ +use std::sync::Arc; + +use kube::api::{Api, Patch, PatchParams}; +use kube::runtime::controller::Action; +use kube::{Client, ResourceExt}; +use tracing::info; + +use sonarr::apis::language_profile_api; +use sonarr::models::{Language, LanguageProfileItemResource, LanguageProfileResource}; + +use crate::Context; +use crate::crds::language_profile::LanguageType; +use crate::crds::{SonarrLanguageProfile, SonarrLanguageProfileStatus}; +use crate::error::{Error, Result}; + +use super::tag::get_sonarr_config; +use super::traits::{REQUEUE_DURATION, reconcile_with_finalizer, run_controller}; +use super::{ready_condition, update_conditions}; + +/// Start the SonarrLanguageProfile controller +pub async fn run(client: Client, context: Arc) { + run_controller::( + client, + context, + "SonarrLanguageProfile", + reconcile, + ) + .await; +} + +async fn reconcile(obj: Arc, ctx: Arc) -> Result { + reconcile_with_finalizer(obj, ctx, reconcile_apply, reconcile_cleanup).await +} + +async fn reconcile_apply(profile: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = profile + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + let name = profile.name_any(); + + info!("Reconciling SonarrLanguageProfile: {}/{}", namespace, name); + + let config = + get_sonarr_config(&ctx, client, &namespace, &profile.spec.sonarr_instance_ref).await?; + + // Build language profile resource + let mut resource = LanguageProfileResource::new(); + resource.name = Some(Some(profile.spec.name.clone())); + resource.upgrade_allowed = Some(profile.spec.upgrade_allowed); + resource.cutoff = Some(Box::new(convert_language(&profile.spec.cutoff_language))); + + // Convert languages + let languages: Vec = profile + .spec + .languages + .iter() + .map(|item| { + let mut lang_item = LanguageProfileItemResource::new(); + lang_item.language = Some(Box::new(convert_language(&item.language))); + lang_item.allowed = Some(item.allowed); + lang_item + }) + .collect(); + resource.languages = Some(Some(languages)); + + let sonarr_profile = if let Some(id) = profile.status.as_ref().and_then(|s| s.id) { + resource.id = Some(id); + match language_profile_api::update_language_profile( + &config, + &id.to_string(), + Some(resource.clone()), + ) + .await + { + Ok(p) => p, + Err(_) => { + resource.id = None; + language_profile_api::create_language_profile(&config, Some(resource)).await? + } + } + } else { + let existing = language_profile_api::list_language_profile(&config).await?; + if let Some(existing_item) = existing + .iter() + .find(|p| p.name.as_ref().and_then(|n| n.as_ref()) == Some(&profile.spec.name)) + { + existing_item.clone() + } else { + language_profile_api::create_language_profile(&config, Some(resource)).await? + } + }; + + // Update status + let api: Api = Api::namespaced(client.clone(), &namespace); + let mut conditions = profile + .status + .as_ref() + .map(|s| s.conditions.clone()) + .unwrap_or_default(); + update_conditions( + &mut conditions, + ready_condition(true, "Synced", "Language profile synchronized with Sonarr"), + ); + + let status = SonarrLanguageProfileStatus { + conditions, + id: sonarr_profile.id, + observed_generation: profile.metadata.generation.unwrap_or(0), + }; + + let status_patch = serde_json::json!({ "status": status }); + api.patch_status(&name, &PatchParams::default(), &Patch::Merge(&status_patch)) + .await?; + + Ok(Action::requeue(REQUEUE_DURATION)) +} + +fn convert_language(lang: &LanguageType) -> Language { + let mut language = Language::new(); + language.id = Some(get_language_id(lang)); + language.name = Some(Some(format!("{:?}", lang))); + language +} + +fn get_language_id(lang: &LanguageType) -> i32 { + match lang { + LanguageType::Unknown => -1, + LanguageType::English => 1, + LanguageType::French => 2, + LanguageType::Spanish => 3, + LanguageType::German => 4, + LanguageType::Italian => 5, + LanguageType::Danish => 6, + LanguageType::Dutch => 7, + LanguageType::Japanese => 8, + LanguageType::Icelandic => 9, + LanguageType::Chinese => 10, + LanguageType::Russian => 11, + LanguageType::Polish => 12, + LanguageType::Vietnamese => 13, + LanguageType::Swedish => 14, + LanguageType::Norwegian => 15, + LanguageType::Finnish => 16, + LanguageType::Turkish => 17, + LanguageType::Portuguese => 18, + LanguageType::Flemish => 19, + LanguageType::Greek => 20, + LanguageType::Korean => 21, + LanguageType::Hungarian => 22, + LanguageType::Hebrew => 23, + LanguageType::Lithuanian => 24, + LanguageType::Czech => 25, + LanguageType::Hindi => 26, + LanguageType::Romanian => 27, + LanguageType::Thai => 28, + LanguageType::Bulgarian => 29, + LanguageType::PortugueseBrazil => 30, + LanguageType::Arabic => 31, + LanguageType::Ukrainian => 32, + LanguageType::Persian => 33, + LanguageType::Bengali => 34, + LanguageType::Slovak => 35, + LanguageType::Latvian => 36, + LanguageType::SpanishLatino => 37, + LanguageType::Catalan => 38, + LanguageType::Croatian => 39, + LanguageType::Serbian => 40, + LanguageType::Bosnian => 41, + LanguageType::Estonian => 42, + LanguageType::Tamil => 43, + LanguageType::Indonesian => 44, + LanguageType::Telugu => 45, + LanguageType::Macedonian => 46, + LanguageType::Slovenian => 47, + LanguageType::Malay => 48, + LanguageType::Original => -2, + LanguageType::Any => 0, + } +} + +async fn reconcile_cleanup( + profile: Arc, + ctx: Arc, +) -> Result { + let client = &ctx.client; + let namespace = profile + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + + info!( + "Cleaning up SonarrLanguageProfile: {}/{}", + namespace, + profile.name_any() + ); + + if let Some(id) = profile.status.as_ref().and_then(|s| s.id) { + if let Ok(config) = + get_sonarr_config(&ctx, client, &namespace, &profile.spec.sonarr_instance_ref).await + { + let _ = language_profile_api::delete_language_profile(&config, id).await; + } + } + + Ok(Action::await_change()) +} diff --git a/src/controllers/media_management_config.rs b/src/controllers/media_management_config.rs new file mode 100644 index 0000000..e3bef3b --- /dev/null +++ b/src/controllers/media_management_config.rs @@ -0,0 +1,341 @@ +//! Controller for SonarrMediaManagementConfig +//! +//! This controller manages media management configuration for Sonarr instances. +//! Only one SonarrMediaManagementConfig per Sonarr instance is allowed. + +use std::sync::Arc; + +use kube::api::{Api, ListParams, Patch, PatchParams}; +use kube::runtime::controller::Action; +use kube::{Client, ResourceExt}; +use tracing::{info, warn}; + +use sonarr::apis::media_management_config_api; +use sonarr::models::{ + EpisodeTitleRequiredType, FileDateType, MediaManagementConfigResource, ProperDownloadTypes, + RescanAfterRefreshType, +}; + +use crate::Context; +use crate::crds::media_management_config::{ + EpisodeTitleRequiredType as CrdEpisodeTitleRequiredType, + FileDateType as CrdFileDateType, ProperDownloadType, RescanAfterRefreshType as CrdRescanType, + SonarrMediaManagementConfig, SonarrMediaManagementConfigStatus, +}; +use crate::error::{Error, Result}; + +use super::tag::get_sonarr_config; +use super::traits::{REQUEUE_DURATION, reconcile_with_finalizer, run_controller}; +use super::{ready_condition, update_conditions}; + +/// Start the SonarrMediaManagementConfig controller +pub async fn run(client: Client, context: Arc) { + run_controller::( + client, + context, + "SonarrMediaManagementConfig", + reconcile, + ) + .await; +} + +async fn reconcile( + obj: Arc, + ctx: Arc, +) -> Result { + reconcile_with_finalizer(obj, ctx, reconcile_apply, reconcile_cleanup).await +} + +/// Check if another SonarrMediaManagementConfig exists for the same Sonarr instance +async fn check_singleton( + client: &Client, + namespace: &str, + current_name: &str, + instance_ref_name: &str, + instance_ref_namespace: Option<&str>, +) -> Result> { + let api: Api = Api::namespaced(client.clone(), namespace); + let configs = api.list(&ListParams::default()).await?; + + for config in configs.items { + let config_name = config.name_any(); + if config_name == current_name { + continue; + } + + let ref_name = &config.spec.sonarr_instance_ref.name; + let ref_ns = config.spec.sonarr_instance_ref.namespace.as_deref(); + + if ref_name == instance_ref_name && ref_ns == instance_ref_namespace { + // Found another config for the same instance + // Check which one is older + let current_created = config.metadata.creation_timestamp.as_ref(); + if current_created.is_some() { + return Ok(Some(config_name)); + } + } + } + + Ok(None) +} + +async fn reconcile_apply( + config: Arc, + ctx: Arc, +) -> Result { + let client = &ctx.client; + let namespace = config + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + let name = config.name_any(); + + info!( + "Reconciling SonarrMediaManagementConfig: {}/{}", + namespace, name + ); + + // Check singleton constraint + if let Some(existing_name) = check_singleton( + client, + &namespace, + &name, + &config.spec.sonarr_instance_ref.name, + config.spec.sonarr_instance_ref.namespace.as_deref(), + ) + .await? + { + warn!( + "Another SonarrMediaManagementConfig '{}' already exists for Sonarr instance '{}'. Only one config per instance is allowed.", + existing_name, config.spec.sonarr_instance_ref.name + ); + + // Update status to show conflict + let api: Api = Api::namespaced(client.clone(), &namespace); + let mut conditions = config + .status + .as_ref() + .map(|s| s.conditions.clone()) + .unwrap_or_default(); + update_conditions( + &mut conditions, + ready_condition( + false, + "Conflict", + &format!( + "Another config '{}' already exists for this Sonarr instance", + existing_name + ), + ), + ); + + let status = SonarrMediaManagementConfigStatus { + conditions, + observed_generation: config.metadata.generation.unwrap_or(0), + }; + + api.patch_status( + &name, + &PatchParams::apply("sonarr-operator"), + &Patch::Merge(serde_json::json!({ "status": status })), + ) + .await?; + + return Ok(Action::requeue(REQUEUE_DURATION)); + } + + let sonarr_config = + get_sonarr_config(&ctx, client, &namespace, &config.spec.sonarr_instance_ref).await?; + + // Get existing config (there's only one, id=1) + let existing = media_management_config_api::get_media_management_config(&sonarr_config).await?; + + // Build update resource + let mut resource = MediaManagementConfigResource::new(); + resource.id = existing.id; + + // Apply settings from CRD, falling back to existing values + resource.auto_unmonitor_previously_downloaded_episodes = config + .spec + .auto_unmonitor_previously_downloaded_episodes + .or(existing.auto_unmonitor_previously_downloaded_episodes); + + resource.recycle_bin = config + .spec + .recycle_bin + .clone() + .map(Some) + .or(existing.recycle_bin); + + resource.recycle_bin_cleanup_days = config + .spec + .recycle_bin_cleanup_days + .or(existing.recycle_bin_cleanup_days); + + resource.download_propers_and_repacks = config + .spec + .download_propers_and_repacks + .as_ref() + .map(|v| match v { + ProperDownloadType::DoNotPrefer => ProperDownloadTypes::DoNotPrefer, + ProperDownloadType::PreferAndUpgrade => ProperDownloadTypes::PreferAndUpgrade, + ProperDownloadType::DoNotUpgrade => ProperDownloadTypes::DoNotUpgrade, + }) + .or(existing.download_propers_and_repacks); + + resource.create_empty_series_folders = config + .spec + .create_empty_series_folders + .or(existing.create_empty_series_folders); + + resource.delete_empty_folders = config + .spec + .delete_empty_folders + .or(existing.delete_empty_folders); + + resource.file_date = config + .spec + .file_date + .as_ref() + .map(|v| match v { + CrdFileDateType::None => FileDateType::None, + CrdFileDateType::LocalAirDate => FileDateType::LocalAirDate, + CrdFileDateType::UtcAirDate => FileDateType::UtcAirDate, + }) + .or(existing.file_date); + + resource.rescan_after_refresh = config + .spec + .rescan_after_refresh + .as_ref() + .map(|v| match v { + CrdRescanType::Always => RescanAfterRefreshType::Always, + CrdRescanType::AfterManual => RescanAfterRefreshType::AfterManual, + CrdRescanType::Never => RescanAfterRefreshType::Never, + }) + .or(existing.rescan_after_refresh); + + resource.set_permissions_linux = config + .spec + .set_permissions_linux + .or(existing.set_permissions_linux); + + resource.chmod_folder = config + .spec + .chmod_folder + .clone() + .map(Some) + .or(existing.chmod_folder); + + resource.chown_group = config + .spec + .chown_group + .clone() + .map(Some) + .or(existing.chown_group); + + resource.episode_title_required = config + .spec + .episode_title_required + .as_ref() + .map(|v| match v { + CrdEpisodeTitleRequiredType::Always => EpisodeTitleRequiredType::Always, + CrdEpisodeTitleRequiredType::BulkSeasonReleases => { + EpisodeTitleRequiredType::BulkSeasonReleases + } + CrdEpisodeTitleRequiredType::Never => EpisodeTitleRequiredType::Never, + }) + .or(existing.episode_title_required); + + resource.skip_free_space_check_when_importing = config + .spec + .skip_free_space_check_when_importing + .or(existing.skip_free_space_check_when_importing); + + resource.minimum_free_space_when_importing = config + .spec + .minimum_free_space_when_importing + .or(existing.minimum_free_space_when_importing); + + resource.copy_using_hardlinks = config + .spec + .copy_using_hardlinks + .or(existing.copy_using_hardlinks); + + resource.use_script_import = config + .spec + .use_script_import + .or(existing.use_script_import); + + resource.script_import_path = config + .spec + .script_import_path + .clone() + .map(Some) + .or(existing.script_import_path); + + resource.import_extra_files = config + .spec + .import_extra_files + .or(existing.import_extra_files); + + resource.extra_file_extensions = config + .spec + .extra_file_extensions + .clone() + .map(Some) + .or(existing.extra_file_extensions); + + resource.enable_media_info = config + .spec + .enable_media_info + .or(existing.enable_media_info); + + // Update config + let id = existing + .id + .ok_or(Error::MissingObjectKey("media_management_config.id"))?; + media_management_config_api::update_media_management_config( + &sonarr_config, + &id.to_string(), + Some(resource), + ) + .await?; + + // Update status + let api: Api = Api::namespaced(client.clone(), &namespace); + let mut conditions = config + .status + .as_ref() + .map(|s| s.conditions.clone()) + .unwrap_or_default(); + update_conditions( + &mut conditions, + ready_condition( + true, + "Synced", + "Media management config synchronized with Sonarr", + ), + ); + + let status = SonarrMediaManagementConfigStatus { + conditions, + observed_generation: config.metadata.generation.unwrap_or(0), + }; + + api.patch_status( + &name, + &PatchParams::apply("sonarr-operator"), + &Patch::Merge(serde_json::json!({ "status": status })), + ) + .await?; + + Ok(Action::requeue(REQUEUE_DURATION)) +} + +async fn reconcile_cleanup( + _config: Arc, + _ctx: Arc, +) -> Result { + // Config settings persist in Sonarr, nothing to clean up + Ok(Action::await_change()) +} diff --git a/src/controllers/metadata.rs b/src/controllers/metadata.rs new file mode 100644 index 0000000..dbad262 --- /dev/null +++ b/src/controllers/metadata.rs @@ -0,0 +1,117 @@ +use std::sync::Arc; + +use kube::api::{Api, Patch, PatchParams}; +use kube::runtime::controller::Action; +use kube::{Client, ResourceExt}; +use tracing::info; + +use sonarr::apis::metadata_api; +use sonarr::models::MetadataResource; + +use crate::crds::{SonarrMetadata, SonarrMetadataStatus}; +use crate::crds::metadata::MetadataType; +use crate::error::{Error, Result}; +use crate::Context; + +use super::traits::{run_controller, reconcile_with_finalizer, REQUEUE_DURATION}; +use super::tag::get_sonarr_config; +use super::{ready_condition, update_conditions}; + +/// Start the SonarrMetadata controller +pub async fn run(client: Client, context: Arc) { + run_controller::( + client, + context, + "SonarrMetadata", + reconcile, + ).await; +} + +async fn reconcile(obj: Arc, ctx: Arc) -> Result { + reconcile_with_finalizer(obj, ctx, reconcile_apply, reconcile_cleanup).await +} + +async fn reconcile_apply(metadata: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = metadata.namespace().ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + let name = metadata.name_any(); + + info!("Reconciling SonarrMetadata: {}/{}", namespace, name); + + let config = get_sonarr_config(&ctx, client, &namespace, &metadata.spec.sonarr_instance_ref).await?; + + // Build metadata resource + let mut resource = MetadataResource::new(); + resource.name = Some(Some(metadata.spec.name.clone())); + resource.implementation = Some(Some(get_implementation_name(&metadata.spec.metadata_type).to_string())); + resource.config_contract = Some(Some(get_config_contract(&metadata.spec.metadata_type).to_string())); + resource.enable = Some(metadata.spec.enable); + resource.tags = Some(Some(metadata.spec.tags.clone())); + + // Fields would need more complex mapping for the sonarr crate + + let sonarr_metadata = if let Some(id) = metadata.status.as_ref().and_then(|s| s.id) { + resource.id = Some(id); + match metadata_api::update_metadata(&config, id, Some(false), Some(resource.clone())).await { + Ok(m) => m, + Err(_) => { + resource.id = None; + metadata_api::create_metadata(&config, Some(false), Some(resource)).await? + } + } + } else { + let existing = metadata_api::list_metadata(&config).await?; + if let Some(existing_item) = existing.iter().find(|m| m.name.as_ref().and_then(|n| n.as_ref()) == Some(&metadata.spec.name)) { + existing_item.clone() + } else { + metadata_api::create_metadata(&config, Some(false), Some(resource)).await? + } + }; + + // Update status + let api: Api = Api::namespaced(client.clone(), &namespace); + let mut conditions = metadata.status.as_ref().map(|s| s.conditions.clone()).unwrap_or_default(); + update_conditions(&mut conditions, ready_condition(true, "Synced", "Metadata synchronized with Sonarr")); + + let status = SonarrMetadataStatus { + conditions, + id: sonarr_metadata.id, + observed_generation: metadata.metadata.generation.unwrap_or(0), + }; + + let status_patch = serde_json::json!({ "status": status }); + api.patch_status(&name, &PatchParams::default(), &Patch::Merge(&status_patch)).await?; + + Ok(Action::requeue(REQUEUE_DURATION)) +} + +fn get_implementation_name(metadata_type: &MetadataType) -> &'static str { + match metadata_type { + MetadataType::XbmcMetadata => "XbmcMetadata", + MetadataType::RoksboxMetadata => "RoksboxMetadata", + MetadataType::WdtvMetadata => "WdtvMetadata", + } +} + +fn get_config_contract(metadata_type: &MetadataType) -> &'static str { + match metadata_type { + MetadataType::XbmcMetadata => "XbmcMetadataSettings", + MetadataType::RoksboxMetadata => "RoksboxMetadataSettings", + MetadataType::WdtvMetadata => "WdtvMetadataSettings", + } +} + +async fn reconcile_cleanup(metadata: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = metadata.namespace().ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + + info!("Cleaning up SonarrMetadata: {}/{}", namespace, metadata.name_any()); + + if let Some(id) = metadata.status.as_ref().and_then(|s| s.id) { + if let Ok(config) = get_sonarr_config(&ctx, client, &namespace, &metadata.spec.sonarr_instance_ref).await { + let _ = metadata_api::delete_metadata(&config, id).await; + } + } + + Ok(Action::await_change()) +} diff --git a/src/controllers/mod.rs b/src/controllers/mod.rs new file mode 100644 index 0000000..6257294 --- /dev/null +++ b/src/controllers/mod.rs @@ -0,0 +1,27 @@ +pub mod auto_tag; +pub mod custom_format; +pub mod delay_profile; +pub mod download_client; +pub mod download_client_config; +pub mod import_list; +pub mod indexer; +pub mod indexer_config; +pub mod language_profile; +pub mod media_management_config; +pub mod metadata; +pub mod naming_config; +pub mod notification; +pub mod quality_definition; +pub mod quality_profile; +pub mod root_folder; +pub mod series; +pub mod sonarr; +pub mod tag; +pub mod traits; +mod utils; + +pub use traits::{ + ERROR_REQUEUE_DURATION, HasSonarrInstanceRef, REQUEUE_DURATION, get_sonarr_config, + run_controller, update_status_failure, update_status_success, +}; +pub use utils::*; diff --git a/src/controllers/naming_config.rs b/src/controllers/naming_config.rs new file mode 100644 index 0000000..290944a --- /dev/null +++ b/src/controllers/naming_config.rs @@ -0,0 +1,235 @@ +//! Controller for SonarrNamingConfig +//! +//! This controller manages episode naming configuration for Sonarr instances. +//! Only one SonarrNamingConfig per Sonarr instance is allowed. + +use std::sync::Arc; + +use kube::api::{Api, ListParams, Patch, PatchParams}; +use kube::runtime::controller::Action; +use kube::{Client, ResourceExt}; +use tracing::{info, warn}; + +use sonarr::apis::naming_config_api; +use sonarr::models::NamingConfigResource; + +use crate::Context; +use crate::crds::naming_config::{SonarrNamingConfig, SonarrNamingConfigStatus}; +use crate::error::{Error, Result}; + +use super::tag::get_sonarr_config; +use super::traits::{REQUEUE_DURATION, reconcile_with_finalizer, run_controller}; +use super::{ready_condition, update_conditions}; + +/// Start the SonarrNamingConfig controller +pub async fn run(client: Client, context: Arc) { + run_controller::(client, context, "SonarrNamingConfig", reconcile) + .await; +} + +async fn reconcile(obj: Arc, ctx: Arc) -> Result { + reconcile_with_finalizer(obj, ctx, reconcile_apply, reconcile_cleanup).await +} + +/// Check if another SonarrNamingConfig exists for the same Sonarr instance +async fn check_singleton( + client: &Client, + namespace: &str, + current_name: &str, + instance_ref_name: &str, + instance_ref_namespace: Option<&str>, +) -> Result> { + let api: Api = Api::namespaced(client.clone(), namespace); + let configs = api.list(&ListParams::default()).await?; + + for config in configs.items { + let config_name = config.name_any(); + if config_name == current_name { + continue; + } + + let ref_name = &config.spec.sonarr_instance_ref.name; + let ref_ns = config.spec.sonarr_instance_ref.namespace.as_deref(); + + if ref_name == instance_ref_name && ref_ns == instance_ref_namespace { + return Ok(Some(config_name)); + } + } + + Ok(None) +} + +async fn reconcile_apply(config: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = config + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + let name = config.name_any(); + + info!("Reconciling SonarrNamingConfig: {}/{}", namespace, name); + + // Check singleton constraint + if let Some(existing_name) = check_singleton( + client, + &namespace, + &name, + &config.spec.sonarr_instance_ref.name, + config.spec.sonarr_instance_ref.namespace.as_deref(), + ) + .await? + { + warn!( + "Another SonarrNamingConfig '{}' already exists for Sonarr instance '{}'. Only one config per instance is allowed.", + existing_name, config.spec.sonarr_instance_ref.name + ); + + let api: Api = Api::namespaced(client.clone(), &namespace); + let mut conditions = config + .status + .as_ref() + .map(|s| s.conditions.clone()) + .unwrap_or_default(); + update_conditions( + &mut conditions, + ready_condition( + false, + "Conflict", + &format!( + "Another config '{}' already exists for this Sonarr instance", + existing_name + ), + ), + ); + + let status = SonarrNamingConfigStatus { + conditions, + observed_generation: config.metadata.generation.unwrap_or(0), + }; + + api.patch_status( + &name, + &PatchParams::apply("sonarr-operator"), + &Patch::Merge(serde_json::json!({ "status": status })), + ) + .await?; + + return Ok(Action::requeue(REQUEUE_DURATION)); + } + + let sonarr_config = + get_sonarr_config(&ctx, client, &namespace, &config.spec.sonarr_instance_ref).await?; + + // Get existing config (there's only one, id=1) + let existing = naming_config_api::get_naming_config(&sonarr_config).await?; + + // Build update resource + let mut resource = NamingConfigResource::new(); + resource.id = existing.id; + + resource.rename_episodes = config.spec.rename_episodes.or(existing.rename_episodes); + + resource.replace_illegal_characters = config + .spec + .replace_illegal_characters + .or(existing.replace_illegal_characters); + + resource.colon_replacement_format = config + .spec + .colon_replacement_format + .or(existing.colon_replacement_format); + + resource.custom_colon_replacement_format = config + .spec + .custom_colon_replacement_format + .clone() + .map(Some) + .or(existing.custom_colon_replacement_format); + + resource.multi_episode_style = config + .spec + .multi_episode_style + .or(existing.multi_episode_style); + + resource.standard_episode_format = config + .spec + .standard_episode_format + .clone() + .map(Some) + .or(existing.standard_episode_format); + + resource.daily_episode_format = config + .spec + .daily_episode_format + .clone() + .map(Some) + .or(existing.daily_episode_format); + + resource.anime_episode_format = config + .spec + .anime_episode_format + .clone() + .map(Some) + .or(existing.anime_episode_format); + + resource.series_folder_format = config + .spec + .series_folder_format + .clone() + .map(Some) + .or(existing.series_folder_format); + + resource.season_folder_format = config + .spec + .season_folder_format + .clone() + .map(Some) + .or(existing.season_folder_format); + + resource.specials_folder_format = config + .spec + .specials_folder_format + .clone() + .map(Some) + .or(existing.specials_folder_format); + + // Update config + let id = existing + .id + .ok_or(Error::MissingObjectKey("naming_config.id"))?; + naming_config_api::update_naming_config(&sonarr_config, &id.to_string(), Some(resource)) + .await?; + + // Update status + let api: Api = Api::namespaced(client.clone(), &namespace); + let mut conditions = config + .status + .as_ref() + .map(|s| s.conditions.clone()) + .unwrap_or_default(); + update_conditions( + &mut conditions, + ready_condition(true, "Synced", "Naming config synchronized with Sonarr"), + ); + + let status = SonarrNamingConfigStatus { + conditions, + observed_generation: config.metadata.generation.unwrap_or(0), + }; + + api.patch_status( + &name, + &PatchParams::apply("sonarr-operator"), + &Patch::Merge(serde_json::json!({ "status": status })), + ) + .await?; + + Ok(Action::requeue(REQUEUE_DURATION)) +} + +async fn reconcile_cleanup( + _config: Arc, + _ctx: Arc, +) -> Result { + // Config settings persist in Sonarr, nothing to clean up + Ok(Action::await_change()) +} diff --git a/src/controllers/notification.rs b/src/controllers/notification.rs new file mode 100644 index 0000000..bc1d810 --- /dev/null +++ b/src/controllers/notification.rs @@ -0,0 +1,138 @@ +use std::sync::Arc; + +use kube::api::{Api, Patch, PatchParams}; +use kube::runtime::controller::Action; +use kube::{Client, ResourceExt}; +use tracing::info; + +use sonarr::apis::notification_api; +use sonarr::models::NotificationResource; + +use crate::crds::{SonarrNotification, SonarrNotificationStatus}; +use crate::crds::notification::NotificationType; +use crate::error::{Error, Result}; +use crate::Context; + +use super::traits::{run_controller, reconcile_with_finalizer, REQUEUE_DURATION}; +use super::tag::get_sonarr_config; +use super::{ready_condition, update_conditions}; + +/// Start the SonarrNotification controller +pub async fn run(client: Client, context: Arc) { + run_controller::( + client, + context, + "SonarrNotification", + reconcile, + ).await; +} + +async fn reconcile(obj: Arc, ctx: Arc) -> Result { + reconcile_with_finalizer(obj, ctx, reconcile_apply, reconcile_cleanup).await +} + +async fn reconcile_apply(notification: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = notification.namespace().ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + let name = notification.name_any(); + + info!("Reconciling SonarrNotification: {}/{}", namespace, name); + + let config = get_sonarr_config(&ctx, client, &namespace, ¬ification.spec.sonarr_instance_ref).await?; + + // Build notification resource + let mut n_resource = NotificationResource::new(); + n_resource.name = Some(Some(notification.spec.name.clone())); + n_resource.implementation = Some(Some(get_implementation_name(¬ification.spec.notification_type).to_string())); + n_resource.on_grab = Some(notification.spec.triggers.on_grab); + n_resource.on_download = Some(notification.spec.triggers.on_download); + n_resource.on_upgrade = Some(notification.spec.triggers.on_upgrade); + n_resource.on_rename = Some(notification.spec.triggers.on_rename); + n_resource.on_series_add = Some(notification.spec.triggers.on_series_add); + n_resource.on_series_delete = Some(notification.spec.triggers.on_series_delete); + n_resource.on_episode_file_delete = Some(notification.spec.triggers.on_episode_file_delete); + n_resource.on_episode_file_delete_for_upgrade = Some(notification.spec.triggers.on_episode_file_delete_for_upgrade); + n_resource.on_health_issue = Some(notification.spec.triggers.on_health_issue); + n_resource.on_health_restored = Some(notification.spec.triggers.on_health_restored); + n_resource.on_application_update = Some(notification.spec.triggers.on_application_update); + n_resource.on_manual_interaction_required = Some(notification.spec.triggers.on_manual_interaction_required); + n_resource.include_health_warnings = Some(notification.spec.triggers.include_health_warnings); + n_resource.tags = Some(Some(notification.spec.tags.clone())); + + let sonarr_notification = if let Some(id) = notification.status.as_ref().and_then(|s| s.id) { + n_resource.id = Some(id); + match notification_api::update_notification(&config, id, Some(false), Some(n_resource.clone())).await { + Ok(n) => n, + Err(_) => { + n_resource.id = None; + notification_api::create_notification(&config, Some(false), Some(n_resource)).await? + } + } + } else { + let existing = notification_api::list_notification(&config).await?; + if let Some(existing_n) = existing.iter().find(|n| n.name.as_ref().and_then(|nm| nm.as_ref()) == Some(¬ification.spec.name)) { + existing_n.clone() + } else { + notification_api::create_notification(&config, Some(false), Some(n_resource)).await? + } + }; + + // Update status + let notifications_api: Api = Api::namespaced(client.clone(), &namespace); + let mut conditions = notification.status.as_ref().map(|s| s.conditions.clone()).unwrap_or_default(); + update_conditions(&mut conditions, ready_condition(true, "Synced", "Notification synchronized with Sonarr")); + + let status = SonarrNotificationStatus { + conditions, + id: sonarr_notification.id, + observed_generation: notification.metadata.generation.unwrap_or(0), + }; + + let status_patch = serde_json::json!({ "status": status }); + notifications_api.patch_status(&name, &PatchParams::default(), &Patch::Merge(&status_patch)).await?; + + Ok(Action::requeue(REQUEUE_DURATION)) +} + +fn get_implementation_name(notification_type: &NotificationType) -> &'static str { + match notification_type { + NotificationType::Apprise => "Apprise", + NotificationType::CustomScript => "CustomScript", + NotificationType::Discord => "Discord", + NotificationType::Email => "Email", + NotificationType::Emby => "MediaBrowser", + NotificationType::Gotify => "Gotify", + NotificationType::Join => "Join", + NotificationType::Kodi => "Xbmc", + NotificationType::Mailgun => "Mailgun", + NotificationType::Ntfy => "Ntfy", + NotificationType::Plex => "PlexServer", + NotificationType::Prowl => "Prowl", + NotificationType::Pushbullet => "Pushbullet", + NotificationType::Pushover => "Pushover", + NotificationType::SendGrid => "SendGrid", + NotificationType::Signal => "Signal", + NotificationType::Simplepush => "Simplepush", + NotificationType::Slack => "Slack", + NotificationType::SynologyIndexer => "SynologyIndexer", + NotificationType::Telegram => "Telegram", + NotificationType::Trakt => "Trakt", + NotificationType::Twitter => "Twitter", + NotificationType::Webhook => "Webhook", + } +} + +async fn reconcile_cleanup(notification: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = notification.namespace().ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + + info!("Cleaning up SonarrNotification: {}/{}", namespace, notification.name_any()); + + if let Some(id) = notification.status.as_ref().and_then(|s| s.id) { + if let Ok(config) = get_sonarr_config(&ctx, client, &namespace, ¬ification.spec.sonarr_instance_ref).await { + let _ = notification_api::delete_notification(&config, id).await; + } + } + + Ok(Action::await_change()) +} diff --git a/src/controllers/quality_definition.rs b/src/controllers/quality_definition.rs new file mode 100644 index 0000000..20dd119 --- /dev/null +++ b/src/controllers/quality_definition.rs @@ -0,0 +1,130 @@ +use std::sync::Arc; + +use kube::api::{Api, Patch, PatchParams}; +use kube::runtime::controller::Action; +use kube::{Client, ResourceExt}; +use tracing::info; + +use sonarr::apis::quality_definition_api; +use sonarr::models::QualityDefinitionResource; + +use crate::Context; +use crate::crds::{SonarrQualityDefinition, SonarrQualityDefinitionStatus}; +use crate::error::{Error, Result}; + +use super::tag::get_sonarr_config; +use super::traits::{REQUEUE_DURATION, reconcile_with_finalizer, run_controller}; +use super::{ready_condition, update_conditions}; + +/// Start the SonarrQualityDefinition controller +pub async fn run(client: Client, context: Arc) { + run_controller::( + client, + context, + "SonarrQualityDefinition", + reconcile, + ) + .await; +} + +async fn reconcile(obj: Arc, ctx: Arc) -> Result { + reconcile_with_finalizer(obj, ctx, reconcile_apply, reconcile_cleanup).await +} + +async fn reconcile_apply(qd: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = qd + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + let name = qd.name_any(); + + info!( + "Reconciling SonarrQualityDefinition: {}/{}", + namespace, name + ); + + let config = get_sonarr_config(&ctx, client, &namespace, &qd.spec.sonarr_instance_ref).await?; + + // Quality definitions already exist in Sonarr, we just update them + let quality_id = qd.spec.quality_name.to_quality_id(); + + // Get existing quality definitions to find the one we want to update + let existing = quality_definition_api::list_quality_definition(&config).await?; + let existing_qd = existing + .iter() + .find(|q| q.quality.as_ref().and_then(|quality| quality.id) == Some(quality_id)); + + let sonarr_qd = if let Some(existing_item) = existing_qd { + let mut resource = QualityDefinitionResource::new(); + resource.id = existing_item.id; + resource.quality = existing_item.quality.clone(); + resource.weight = existing_item.weight; + + // Apply our customizations + if let Some(title) = &qd.spec.title { + resource.title = Some(Some(title.clone())); + } else { + resource.title = existing_item.title.clone(); + } + resource.min_size = Some(qd.spec.min_size); + resource.max_size = Some(qd.spec.max_size); + resource.preferred_size = Some(qd.spec.preferred_size); + + let id = existing_item + .id + .ok_or(Error::MissingObjectKey("quality_definition.id"))?; + quality_definition_api::update_quality_definition(&config, &id.to_string(), Some(resource)) + .await? + } else { + return Err(Error::SonarrApiError(format!( + "Quality definition for {:?} not found in Sonarr", + qd.spec.quality_name + ))); + }; + + // Update status + let api: Api = Api::namespaced(client.clone(), &namespace); + let mut conditions = qd + .status + .as_ref() + .map(|s| s.conditions.clone()) + .unwrap_or_default(); + update_conditions( + &mut conditions, + ready_condition( + true, + "Synced", + "Quality definition synchronized with Sonarr", + ), + ); + + let status = SonarrQualityDefinitionStatus { + conditions, + id: sonarr_qd.id, + observed_generation: qd.metadata.generation.unwrap_or(0), + }; + + let status_patch = serde_json::json!({ "status": status }); + api.patch_status(&name, &PatchParams::default(), &Patch::Merge(&status_patch)) + .await?; + + Ok(Action::requeue(REQUEUE_DURATION)) +} + +async fn reconcile_cleanup(qd: Arc, _ctx: Arc) -> Result { + let namespace = qd + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + + info!( + "Cleaning up SonarrQualityDefinition: {}/{}", + namespace, + qd.name_any() + ); + + // Quality definitions cannot be deleted in Sonarr, only modified + // So cleanup is a no-op - we just let the CRD be removed + // The quality definition in Sonarr will remain with its current settings + + Ok(Action::await_change()) +} diff --git a/src/controllers/quality_profile.rs b/src/controllers/quality_profile.rs new file mode 100644 index 0000000..916dfbb --- /dev/null +++ b/src/controllers/quality_profile.rs @@ -0,0 +1,132 @@ +use std::sync::Arc; + +use kube::api::{Api, Patch, PatchParams}; +use kube::runtime::controller::Action; +use kube::{Client, ResourceExt}; +use tracing::info; + +use sonarr::apis::quality_profile_api; +use sonarr::models::QualityProfileResource; + +use crate::Context; +use crate::crds::{SonarrQualityProfile, SonarrQualityProfileStatus}; +use crate::error::{Error, Result}; + +use super::tag::get_sonarr_config; +use super::traits::{REQUEUE_DURATION, reconcile_with_finalizer, run_controller}; +use super::{ready_condition, update_conditions}; + +/// Start the SonarrQualityProfile controller +pub async fn run(client: Client, context: Arc) { + run_controller::( + client, + context, + "SonarrQualityProfile", + reconcile, + ) + .await; +} + +async fn reconcile(obj: Arc, ctx: Arc) -> Result { + reconcile_with_finalizer(obj, ctx, reconcile_apply, reconcile_cleanup).await +} + +async fn reconcile_apply(profile: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = profile + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + let name = profile.name_any(); + + info!("Reconciling SonarrQualityProfile: {}/{}", namespace, name); + + let config = + get_sonarr_config(&ctx, client, &namespace, &profile.spec.sonarr_instance_ref).await?; + + // Build quality profile resource + let mut qp_resource = QualityProfileResource::new(); + qp_resource.name = Some(Some(profile.spec.name.clone())); + qp_resource.upgrade_allowed = Some(profile.spec.upgrade_allowed); + qp_resource.cutoff = Some(profile.spec.cutoff); + qp_resource.cutoff_format_score = profile.spec.cutoff_format_score; + qp_resource.min_format_score = profile.spec.min_format_score; + // Note: Quality groups would need more complex mapping + + let sonarr_profile = if let Some(id) = profile.status.as_ref().and_then(|s| s.id) { + qp_resource.id = Some(id); + match quality_profile_api::update_quality_profile( + &config, + id.to_string().as_str(), + Some(qp_resource.clone()), + ) + .await + { + Ok(p) => p, + Err(_) => { + qp_resource.id = None; + quality_profile_api::create_quality_profile(&config, Some(qp_resource)).await? + } + } + } else { + let existing = quality_profile_api::list_quality_profile(&config).await?; + if let Some(existing_profile) = existing + .iter() + .find(|p| p.name.as_ref().and_then(|n| n.as_ref()) == Some(&profile.spec.name)) + { + existing_profile.clone() + } else { + quality_profile_api::create_quality_profile(&config, Some(qp_resource)).await? + } + }; + + // Update status + let profiles_api: Api = Api::namespaced(client.clone(), &namespace); + let mut conditions = profile + .status + .as_ref() + .map(|s| s.conditions.clone()) + .unwrap_or_default(); + update_conditions( + &mut conditions, + ready_condition(true, "Synced", "Quality profile synchronized with Sonarr"), + ); + + let status = SonarrQualityProfileStatus { + conditions, + id: sonarr_profile.id, + observed_generation: profile.metadata.generation.unwrap_or(0), + }; + + let status_patch = serde_json::json!({ "status": status }); + profiles_api + .patch_status(&name, &PatchParams::default(), &Patch::Merge(&status_patch)) + .await?; + + Ok(Action::requeue(REQUEUE_DURATION)) +} + +async fn reconcile_cleanup( + profile: Arc, + ctx: Arc, +) -> Result { + let client = &ctx.client; + let namespace = profile + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + + info!( + "Cleaning up SonarrQualityProfile: {}/{}", + namespace, + profile.name_any() + ); + + if let Some(id) = profile.status.as_ref().and_then(|s| s.id) { + if let Ok(config) = + get_sonarr_config(&ctx, client, &namespace, &profile.spec.sonarr_instance_ref).await + { + let _ = quality_profile_api::delete_quality_profile(&config, id).await; + } + } + + Ok(Action::await_change()) +} diff --git a/src/controllers/root_folder.rs b/src/controllers/root_folder.rs new file mode 100644 index 0000000..c1e211a --- /dev/null +++ b/src/controllers/root_folder.rs @@ -0,0 +1,113 @@ +use std::sync::Arc; + +use kube::api::{Api, Patch, PatchParams}; +use kube::runtime::controller::Action; +use kube::{Client, ResourceExt}; +use tracing::info; + +use sonarr::apis::root_folder_api; +use sonarr::models::RootFolderResource; + +use crate::Context; +use crate::crds::{SonarrRootFolder, SonarrRootFolderStatus}; +use crate::error::{Error, Result}; + +use super::tag::get_sonarr_config; +use super::traits::{REQUEUE_DURATION, reconcile_with_finalizer, run_controller}; +use super::{ready_condition, update_conditions}; + +/// Start the SonarrRootFolder controller +pub async fn run(client: Client, context: Arc) { + run_controller::(client, context, "SonarrRootFolder", reconcile).await; +} + +async fn reconcile(obj: Arc, ctx: Arc) -> Result { + reconcile_with_finalizer(obj, ctx, reconcile_apply, reconcile_cleanup).await +} + +async fn reconcile_apply(folder: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = folder + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + let name = folder.name_any(); + + info!("Reconciling SonarrRootFolder: {}/{}", namespace, name); + + let config = + get_sonarr_config(&ctx, client, &namespace, &folder.spec.sonarr_instance_ref).await?; + + // Get or create root folder + let sonarr_folder = if let Some(id) = folder.status.as_ref().and_then(|s| s.id) { + match root_folder_api::get_root_folder_by_id(&config, id).await { + Ok(f) => f, + Err(_) => { + let mut rf = RootFolderResource::new(); + rf.path = Some(Some(folder.spec.path.clone())); + root_folder_api::create_root_folder(&config, Some(rf)).await? + } + } + } else { + let existing = root_folder_api::list_root_folder(&config).await?; + if let Some(existing_folder) = existing + .iter() + .find(|f| f.path.as_ref().and_then(|p| p.as_ref()) == Some(&folder.spec.path)) + { + existing_folder.clone() + } else { + let mut rf = RootFolderResource::new(); + rf.path = Some(Some(folder.spec.path.clone())); + root_folder_api::create_root_folder(&config, Some(rf)).await? + } + }; + + // Update status + let folders_api: Api = Api::namespaced(client.clone(), &namespace); + let mut conditions = folder + .status + .as_ref() + .map(|s| s.conditions.clone()) + .unwrap_or_default(); + update_conditions( + &mut conditions, + ready_condition(true, "Synced", "Root folder synchronized with Sonarr"), + ); + + let status = SonarrRootFolderStatus { + conditions, + id: sonarr_folder.id, + accessible: sonarr_folder.accessible, + free_space: sonarr_folder.free_space.flatten(), + observed_generation: folder.metadata.generation.unwrap_or(0), + }; + + let status_patch = serde_json::json!({ "status": status }); + folders_api + .patch_status(&name, &PatchParams::default(), &Patch::Merge(&status_patch)) + .await?; + + Ok(Action::requeue(REQUEUE_DURATION)) +} + +async fn reconcile_cleanup(folder: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = folder + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + + info!( + "Cleaning up SonarrRootFolder: {}/{}", + namespace, + folder.name_any() + ); + + if let Some(id) = folder.status.as_ref().and_then(|s| s.id) { + if let Ok(config) = + get_sonarr_config(&ctx, client, &namespace, &folder.spec.sonarr_instance_ref).await + { + let _ = root_folder_api::delete_root_folder(&config, id).await; + } + } + + Ok(Action::await_change()) +} diff --git a/src/controllers/series.rs b/src/controllers/series.rs new file mode 100644 index 0000000..4776403 --- /dev/null +++ b/src/controllers/series.rs @@ -0,0 +1,162 @@ +use std::sync::Arc; + +use kube::api::{Api, Patch, PatchParams}; +use kube::runtime::controller::Action; +use kube::{Client, ResourceExt}; +use tracing::{info, warn}; + +use sonarr::apis::{series_api, series_lookup_api}; +use sonarr::models::{AddSeriesOptions, MonitorTypes, SeriesTypes}; + +use crate::crds::{SonarrSeries, SonarrSeriesStatus}; +use crate::crds::series::{MonitorType, SeriesType}; +use crate::error::{Error, Result}; +use crate::Context; + +use super::traits::{run_controller, reconcile_with_finalizer, REQUEUE_DURATION}; +use super::tag::get_sonarr_config; +use super::{ready_condition, update_conditions}; + +/// Start the SonarrSeries controller +pub async fn run(client: Client, context: Arc) { + run_controller::( + client, + context, + "SonarrSeries", + reconcile, + ).await; +} + +async fn reconcile(obj: Arc, ctx: Arc) -> Result { + reconcile_with_finalizer(obj, ctx, reconcile_apply, reconcile_cleanup).await +} + +async fn reconcile_apply(series: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = series.namespace().ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + let name = series.name_any(); + + info!("Reconciling SonarrSeries: {}/{}", namespace, name); + + let config = get_sonarr_config(&ctx, client, &namespace, &series.spec.sonarr_instance_ref).await?; + + // Check if series exists by ID or by TVDB ID + let existing_series = if let Some(id) = series.status.as_ref().and_then(|s| s.id) { + match series_api::get_series_by_id(&config, id, Some(false)).await { + Ok(s) => Some(s), + Err(_) => None, + } + } else { + // Try to find by TVDB ID + let all_series = series_api::list_series(&config, Some(series.spec.tvdb_id), Some(true)).await?; + all_series.into_iter().find(|s| s.tvdb_id == Some(series.spec.tvdb_id)) + }; + + let sonarr_series = if let Some(mut existing) = existing_series { + // Update existing series + existing.monitored = Some(series.spec.monitored); + // Use quality_profile.id if available + if let Some(id) = series.spec.quality_profile.id { + existing.quality_profile_id = Some(id); + } + existing.root_folder_path = Some(Some(series.spec.root_folder_path.clone())); + existing.season_folder = Some(series.spec.season_folder); + existing.tags = Some(Some(series.spec.tags.clone())); + existing.series_type = Some(convert_series_type(&series.spec.series_type)); + + series_api::update_series(&config, existing.id.unwrap_or(0).to_string().as_str(), Some(false), Some(existing)).await? + } else { + // Need to lookup series from TVDB first + let lookup_results = series_lookup_api::list_series_lookup(&config, Some(&format!("tvdb:{}", series.spec.tvdb_id))).await?; + + if lookup_results.is_empty() { + return Err(Error::Other(format!("Series with TVDB ID {} not found", series.spec.tvdb_id))); + } + + let mut new_series = lookup_results.into_iter().next().unwrap(); + new_series.monitored = Some(series.spec.monitored); + // Use quality_profile.id if available + if let Some(id) = series.spec.quality_profile.id { + new_series.quality_profile_id = Some(id); + } + new_series.root_folder_path = Some(Some(series.spec.root_folder_path.clone())); + new_series.season_folder = Some(series.spec.season_folder); + new_series.tags = Some(Some(series.spec.tags.clone())); + new_series.series_type = Some(convert_series_type(&series.spec.series_type)); + + // Set add options + let mut add_options = AddSeriesOptions::new(); + add_options.monitor = Some(convert_monitor_type(&series.spec.add_options.monitor)); + add_options.search_for_missing_episodes = Some(series.spec.add_options.search_for_missing_episodes); + add_options.search_for_cutoff_unmet_episodes = Some(series.spec.add_options.search_for_cutoff_unmet_episodes); + + new_series.add_options = Some(Box::new(add_options)); + + series_api::create_series(&config, Some(new_series)).await? + }; + + // Update status + let series_api_k8s: Api = Api::namespaced(client.clone(), &namespace); + let mut conditions = series.status.as_ref().map(|s| s.conditions.clone()).unwrap_or_default(); + update_conditions(&mut conditions, ready_condition(true, "Synced", "Series synchronized with Sonarr")); + + // Extract statistics from the series + let stats = sonarr_series.statistics.as_ref(); + + let status = SonarrSeriesStatus { + conditions, + id: sonarr_series.id, + observed_generation: series.metadata.generation.unwrap_or(0), + episode_count: stats.and_then(|s| s.episode_count), + episode_file_count: stats.and_then(|s| s.episode_file_count), + percent_complete: stats.and_then(|s| s.percent_of_episodes), + next_airing: sonarr_series.next_airing.flatten().map(|d| d.to_string()), + previous_airing: sonarr_series.previous_airing.flatten().map(|d| d.to_string()), + network: sonarr_series.network.flatten(), + series_status: sonarr_series.status.map(|s| format!("{:?}", s)), + }; + + let status_patch = serde_json::json!({ "status": status }); + series_api_k8s.patch_status(&name, &PatchParams::default(), &Patch::Merge(&status_patch)).await?; + + Ok(Action::requeue(REQUEUE_DURATION)) +} + +fn convert_monitor_type(monitor: &MonitorType) -> MonitorTypes { + match monitor { + MonitorType::All => MonitorTypes::All, + MonitorType::Future => MonitorTypes::Future, + MonitorType::Missing => MonitorTypes::Missing, + MonitorType::Existing => MonitorTypes::Existing, + MonitorType::FirstSeason => MonitorTypes::FirstSeason, + MonitorType::LastSeason => MonitorTypes::LastSeason, + MonitorType::Recent => MonitorTypes::Recent, + MonitorType::Pilot => MonitorTypes::Pilot, + MonitorType::None => MonitorTypes::None, + } +} + +fn convert_series_type(series_type: &SeriesType) -> SeriesTypes { + match series_type { + SeriesType::Standard => SeriesTypes::Standard, + SeriesType::Daily => SeriesTypes::Daily, + SeriesType::Anime => SeriesTypes::Anime, + } +} + +async fn reconcile_cleanup(series: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = series.namespace().ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + + info!("Cleaning up SonarrSeries: {}/{}", namespace, series.name_any()); + + if let Some(id) = series.status.as_ref().and_then(|s| s.id) { + if let Ok(config) = get_sonarr_config(&ctx, client, &namespace, &series.spec.sonarr_instance_ref).await { + // Default to not deleting files on cleanup + warn!("Deleting series {} from Sonarr", id); + let _ = series_api::delete_series(&config, id, Some(false), Some(false)).await; + } + } + + Ok(Action::await_change()) +} diff --git a/src/controllers/sonarr.rs b/src/controllers/sonarr.rs new file mode 100644 index 0000000..9fa25d3 --- /dev/null +++ b/src/controllers/sonarr.rs @@ -0,0 +1,1129 @@ +use std::collections::BTreeMap; +use std::sync::Arc; +use std::time::Duration; + +use futures::StreamExt; +use k8s_openapi::api::apps::v1::{Deployment, DeploymentSpec}; +use k8s_openapi::api::core::v1::{ + Container, ContainerPort, EnvVar, PersistentVolumeClaim, PersistentVolumeClaimSpec, PodSpec, + PodTemplateSpec, ResourceRequirements, Secret, Service, ServicePort, ServiceSpec, Volume, + VolumeMount, VolumeResourceRequirements, +}; +use k8s_openapi::api::networking::v1::{ + HTTPIngressPath, HTTPIngressRuleValue, Ingress, IngressBackend, IngressRule, + IngressServiceBackend, IngressSpec, IngressTLS, ServiceBackendPort, +}; +use k8s_openapi::apimachinery::pkg::api::resource::Quantity; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::{LabelSelector, ObjectMeta, OwnerReference}; +use kube::api::{Api, Patch, PatchParams, PostParams}; +use kube::runtime::controller::{Action, Controller}; +use kube::runtime::{ + finalizer::{Event, finalizer}, + watcher, +}; +use kube::{Client, Resource, ResourceExt}; +use tracing::{debug, error, info}; + +use crate::Context; +use crate::crds::{FINALIZER, LABEL_APP, LABEL_INSTANCE, LABEL_MANAGED_BY, Sonarr, SonarrStatus}; +use crate::error::{Error, Result}; + +use super::{progressing_condition, ready_condition, update_conditions}; + +/// Start the Sonarr controller +pub async fn run(client: Client, context: Arc) { + let sonarrs = Api::::all(client.clone()); + + info!("Starting Sonarr controller"); + + Controller::new(sonarrs, watcher::Config::default()) + .shutdown_on_signal() + .run(reconcile, error_policy, context) + .for_each(|res| async move { + match res { + Ok(o) => debug!("Reconciled Sonarr: {:?}", o), + Err(e) => error!("Reconcile error: {:?}", e), + } + }) + .await; +} + +/// Error policy for the controller +fn error_policy(obj: Arc, error: &Error, _ctx: Arc) -> Action { + error!("Error reconciling Sonarr {}: {:?}", obj.name_any(), error); + Action::requeue(Duration::from_secs(60)) +} + +/// Main reconciliation function +async fn reconcile(obj: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = obj + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + let name = obj.name_any(); + + info!("Reconciling Sonarr: {}/{}", namespace, name); + + let instances: Api = Api::namespaced(client.clone(), &namespace); + + // Handle finalizer + finalizer(&instances, FINALIZER, obj.clone(), |event| async { + match event { + Event::Apply(instance) => reconcile_apply(instance, ctx.clone()).await, + Event::Cleanup(instance) => reconcile_cleanup(instance, ctx.clone()).await, + } + }) + .await + .map_err(|e| Error::FinalizerError(Box::new(e))) +} + +/// Reconcile on apply (create/update) +async fn reconcile_apply(instance: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = instance + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + let name = instance.name_any(); + + // Create owner reference + let owner_ref = instance.controller_owner_ref(&()).unwrap(); + + // Create labels + let labels = create_labels(&name); + + // Reconcile PVC + reconcile_pvc(client, &namespace, &instance, &owner_ref, &labels).await?; + + // Reconcile Secret (API key) + reconcile_secret(client, &namespace, &instance, &owner_ref, &labels).await?; + + // Reconcile Deployment + reconcile_deployment(client, &namespace, &instance, &owner_ref, &labels).await?; + + // Reconcile Service + reconcile_service(client, &namespace, &instance, &owner_ref, &labels).await?; + + // Reconcile Ingress (if configured) + if let Some(ref ingress_config) = instance.spec.ingress { + if ingress_config.enabled { + reconcile_ingress( + client, + &namespace, + &instance, + ingress_config, + &owner_ref, + &labels, + ) + .await?; + } + } + + // Reconcile HTTPRoute (if configured) + if let Some(ref http_route_config) = instance.spec.http_route { + if http_route_config.enabled { + reconcile_http_route( + client, + &namespace, + &instance, + http_route_config, + &owner_ref, + &labels, + ) + .await?; + } + } + + // Update status + update_status(client, &namespace, &instance).await?; + + Ok(Action::requeue(Duration::from_secs(300))) +} + +/// Reconcile on cleanup (delete) +async fn reconcile_cleanup(instance: Arc, _ctx: Arc) -> Result { + info!( + "Cleaning up Sonarr: {}/{}", + instance.namespace().unwrap_or_default(), + instance.name_any() + ); + + // Resources are cleaned up automatically via owner references + Ok(Action::await_change()) +} + +fn create_labels(name: &str) -> BTreeMap { + let mut labels = BTreeMap::new(); + labels.insert(LABEL_APP.to_string(), "sonarr".to_string()); + labels.insert(LABEL_INSTANCE.to_string(), name.to_string()); + labels.insert(LABEL_MANAGED_BY.to_string(), "sonarr-operator".to_string()); + labels +} + +async fn reconcile_pvc( + client: &Client, + namespace: &str, + instance: &Sonarr, + owner_ref: &OwnerReference, + labels: &BTreeMap, +) -> Result<()> { + // Skip if using existing claim + if instance.spec.storage.existing_claim.is_some() { + return Ok(()); + } + + let pvc_name = instance.pvc_name(); + let pvc_api: Api = Api::namespaced(client.clone(), namespace); + + // Check if PVC exists + if pvc_api.get_opt(&pvc_name).await?.is_some() { + debug!("PVC {} already exists", pvc_name); + return Ok(()); + } + + info!("Creating PVC: {}", pvc_name); + + // Use defaults if values are empty + let storage_size = if instance.spec.storage.size.is_empty() { + "1Gi".to_string() + } else { + instance.spec.storage.size.clone() + }; + + let access_modes = if instance.spec.storage.access_modes.is_empty() { + vec!["ReadWriteOnce".to_string()] + } else { + instance.spec.storage.access_modes.clone() + }; + + let mut requests = BTreeMap::new(); + requests.insert("storage".to_string(), Quantity(storage_size)); + + let pvc = PersistentVolumeClaim { + metadata: ObjectMeta { + name: Some(pvc_name.clone()), + namespace: Some(namespace.to_string()), + labels: Some(labels.clone()), + owner_references: Some(vec![owner_ref.clone()]), + ..Default::default() + }, + spec: Some(PersistentVolumeClaimSpec { + access_modes: Some(access_modes), + storage_class_name: instance.spec.storage.storage_class.clone(), + resources: Some(VolumeResourceRequirements { + requests: Some(requests), + ..Default::default() + }), + ..Default::default() + }), + ..Default::default() + }; + + pvc_api + .create(&PostParams::default(), &pvc) + .await + .map_err(Error::KubeError)?; + + Ok(()) +} + +async fn reconcile_secret( + client: &Client, + namespace: &str, + instance: &Sonarr, + owner_ref: &OwnerReference, + labels: &BTreeMap, +) -> Result<()> { + // Skip if using existing secret + if instance.spec.api_key_secret_ref.is_some() { + return Ok(()); + } + + let secret_name = instance.api_key_secret_name(); + let secret_api: Api = Api::namespaced(client.clone(), namespace); + + // Check if secret exists + if secret_api.get_opt(&secret_name).await?.is_some() { + debug!("Secret {} already exists", secret_name); + return Ok(()); + } + + info!("Creating API key secret: {}", secret_name); + + // Generate a random API key + let api_key = generate_api_key(); + + let mut data = BTreeMap::new(); + data.insert( + "api-key".to_string(), + base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &api_key), + ); + + let secret = Secret { + metadata: ObjectMeta { + name: Some(secret_name.clone()), + namespace: Some(namespace.to_string()), + labels: Some(labels.clone()), + owner_references: Some(vec![owner_ref.clone()]), + ..Default::default() + }, + string_data: Some({ + let mut sd = BTreeMap::new(); + sd.insert("api-key".to_string(), api_key); + sd + }), + ..Default::default() + }; + + secret_api + .create(&PostParams::default(), &secret) + .await + .map_err(Error::KubeError)?; + + Ok(()) +} + +fn generate_api_key() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + format!("{:032x}", timestamp) +} + +async fn reconcile_deployment( + client: &Client, + namespace: &str, + instance: &Sonarr, + owner_ref: &OwnerReference, + labels: &BTreeMap, +) -> Result<()> { + let deployment_name = instance.deployment_name(); + let deployment_api: Api = Api::namespaced(client.clone(), namespace); + + let deployment = build_deployment(instance, namespace, owner_ref, labels)?; + + let patch_params = PatchParams::apply("sonarr-operator").force(); + deployment_api + .patch(&deployment_name, &patch_params, &Patch::Apply(&deployment)) + .await + .map_err(Error::KubeError)?; + + info!("Deployment {} applied", deployment_name); + + Ok(()) +} + +fn build_deployment( + instance: &Sonarr, + namespace: &str, + owner_ref: &OwnerReference, + labels: &BTreeMap, +) -> Result { + let deployment_name = instance.deployment_name(); + let pvc_name = instance + .spec + .storage + .existing_claim + .clone() + .unwrap_or_else(|| instance.pvc_name()); + + // Build environment variables + let mut env_vars: Vec = vec![ + EnvVar { + name: "PUID".to_string(), + value: Some("1000".to_string()), + ..Default::default() + }, + EnvVar { + name: "PGID".to_string(), + value: Some("1000".to_string()), + ..Default::default() + }, + EnvVar { + name: "TZ".to_string(), + value: Some("Etc/UTC".to_string()), + ..Default::default() + }, + ]; + + // Add user-defined environment variables + for env in &instance.spec.env { + let env_var = if let Some(ref value) = env.value { + EnvVar { + name: env.name.clone(), + value: Some(value.clone()), + ..Default::default() + } + } else if let Some(ref value_from) = env.value_from { + let mut ev = EnvVar { + name: env.name.clone(), + ..Default::default() + }; + if let Some(ref secret_ref) = value_from.secret_key_ref { + ev.value_from = Some(k8s_openapi::api::core::v1::EnvVarSource { + secret_key_ref: Some(k8s_openapi::api::core::v1::SecretKeySelector { + name: secret_ref.name.clone(), + key: secret_ref.key.clone(), + optional: Some(false), + }), + ..Default::default() + }); + } else if let Some(ref cm_ref) = value_from.config_map_key_ref { + ev.value_from = Some(k8s_openapi::api::core::v1::EnvVarSource { + config_map_key_ref: Some(k8s_openapi::api::core::v1::ConfigMapKeySelector { + name: cm_ref.name.clone(), + key: cm_ref.key.clone(), + optional: Some(false), + }), + ..Default::default() + }); + } + ev + } else { + continue; + }; + env_vars.push(env_var); + } + + // Build volume mounts + let mut volume_mounts: Vec = vec![VolumeMount { + name: "config".to_string(), + mount_path: "/config".to_string(), + ..Default::default() + }]; + + for vm in &instance.spec.volume_mounts { + volume_mounts.push(VolumeMount { + name: vm.name.clone(), + mount_path: vm.mount_path.clone(), + sub_path: vm.sub_path.clone(), + read_only: Some(vm.read_only), + ..Default::default() + }); + } + + // Build volumes + let mut volumes: Vec = vec![Volume { + name: "config".to_string(), + persistent_volume_claim: Some( + k8s_openapi::api::core::v1::PersistentVolumeClaimVolumeSource { + claim_name: pvc_name, + read_only: Some(false), + }, + ), + ..Default::default() + }]; + + for v in &instance.spec.volumes { + let mut volume = Volume { + name: v.name.clone(), + ..Default::default() + }; + if let Some(ref pvc) = v.persistent_volume_claim { + volume.persistent_volume_claim = Some( + k8s_openapi::api::core::v1::PersistentVolumeClaimVolumeSource { + claim_name: pvc.claim_name.clone(), + read_only: Some(pvc.read_only), + }, + ); + } + if let Some(ref hp) = v.host_path { + volume.host_path = Some(k8s_openapi::api::core::v1::HostPathVolumeSource { + path: hp.path.clone(), + type_: hp.host_path_type.clone(), + }); + } + if let Some(ref nfs) = v.nfs { + volume.nfs = Some(k8s_openapi::api::core::v1::NFSVolumeSource { + server: nfs.server.clone(), + path: nfs.path.clone(), + read_only: Some(nfs.read_only), + }); + } + if let Some(ref ed) = v.empty_dir { + volume.empty_dir = Some(k8s_openapi::api::core::v1::EmptyDirVolumeSource { + medium: ed.medium.clone(), + size_limit: ed.size_limit.clone().map(Quantity), + }); + } + volumes.push(volume); + } + + // Determine which secret to use for API key + let api_key_secret_name = if let Some(ref secret_ref) = instance.spec.api_key_secret_ref { + secret_ref.name.clone() + } else { + instance.api_key_secret_name() + }; + let api_key_secret_key = instance + .spec + .api_key_secret_ref + .as_ref() + .map(|s| s.key.clone()) + .unwrap_or_else(|| "api-key".to_string()); + + // Build init containers + let mut init_containers = Vec::new(); + + // Add config initialization init container using sed for XML manipulation + // This container creates/updates config.xml with settings from the operator + let config_script = r#"#!/bin/sh +set -e +CONFIG_FILE="/config/config.xml" +API_KEY="${SONARR_API_KEY}" + +if [ -z "$API_KEY" ]; then + echo "Error: SONARR_API_KEY environment variable is not set" + exit 1 +fi + +# Function to set or update an XML element using sed +set_config_value() { + element="$1" + value="$2" + + if grep -q "<${element}>" "$CONFIG_FILE"; then + # Element exists, update it + sed -i "s|<${element}>.*|<${element}>${value}|g" "$CONFIG_FILE" + echo "Updated ${element}" + else + # Element doesn't exist, add it before + sed -i "s|| <${element}>${value}\n|" "$CONFIG_FILE" + echo "Added ${element}" + fi +} + +if [ ! -f "$CONFIG_FILE" ]; then + # Create minimal config.xml + cat > "$CONFIG_FILE" << 'XMLEOF' + + + info + + * + 8989 + 9898 + False + False + None + Docker + Sonarr + +XMLEOF + echo "Created new config.xml" +fi + +# Set the API key +set_config_value "ApiKey" "$API_KEY" + +# Set additional config values from environment if provided +[ -n "$SONARR_PORT" ] && set_config_value "Port" "$SONARR_PORT" +[ -n "$SONARR_URL_BASE" ] && set_config_value "UrlBase" "$SONARR_URL_BASE" +[ -n "$SONARR_BIND_ADDRESS" ] && set_config_value "BindAddress" "$SONARR_BIND_ADDRESS" +[ -n "$SONARR_LOG_LEVEL" ] && set_config_value "LogLevel" "$SONARR_LOG_LEVEL" +[ -n "$SONARR_INSTANCE_NAME" ] && set_config_value "InstanceName" "$SONARR_INSTANCE_NAME" +[ -n "$SONARR_AUTH_METHOD" ] && set_config_value "AuthenticationMethod" "$SONARR_AUTH_METHOD" +[ -n "$SONARR_AUTH_REQUIRED" ] && set_config_value "AuthenticationRequired" "$SONARR_AUTH_REQUIRED" +[ -n "$SONARR_ANALYTICS" ] && set_config_value "AnalyticsEnabled" "$SONARR_ANALYTICS" + +# Ensure proper permissions +chmod 644 "$CONFIG_FILE" +echo "Config initialization complete" +"#; + + // Build environment variables for init container + let mut init_env = vec![EnvVar { + name: "SONARR_API_KEY".to_string(), + value_from: Some(k8s_openapi::api::core::v1::EnvVarSource { + secret_key_ref: Some(k8s_openapi::api::core::v1::SecretKeySelector { + name: api_key_secret_name.clone(), + key: api_key_secret_key.clone(), + optional: Some(false), + }), + ..Default::default() + }), + ..Default::default() + }]; + + // Add port if non-default + let container_port = instance.container_port(); + if container_port != 8989 { + init_env.push(EnvVar { + name: "SONARR_PORT".to_string(), + value: Some(container_port.to_string()), + ..Default::default() + }); + } + + // Add config options from spec.config + if let Some(ref url_base) = instance.spec.config.url_base { + init_env.push(EnvVar { + name: "SONARR_URL_BASE".to_string(), + value: Some(url_base.clone()), + ..Default::default() + }); + } + if let Some(ref bind_address) = instance.spec.config.bind_address { + init_env.push(EnvVar { + name: "SONARR_BIND_ADDRESS".to_string(), + value: Some(bind_address.clone()), + ..Default::default() + }); + } + if let Some(ref log_level) = instance.spec.config.log_level { + init_env.push(EnvVar { + name: "SONARR_LOG_LEVEL".to_string(), + value: Some(log_level.clone()), + ..Default::default() + }); + } + if let Some(ref instance_name) = instance.spec.config.instance_name { + init_env.push(EnvVar { + name: "SONARR_INSTANCE_NAME".to_string(), + value: Some(instance_name.clone()), + ..Default::default() + }); + } + if let Some(ref auth_method) = instance.spec.config.authentication_method { + init_env.push(EnvVar { + name: "SONARR_AUTH_METHOD".to_string(), + value: Some(auth_method.clone()), + ..Default::default() + }); + } + if let Some(auth_required) = instance.spec.config.authentication_required { + init_env.push(EnvVar { + name: "SONARR_AUTH_REQUIRED".to_string(), + value: Some(if auth_required { "True" } else { "False" }.to_string()), + ..Default::default() + }); + } + if let Some(analytics) = instance.spec.config.analytics_enabled { + init_env.push(EnvVar { + name: "SONARR_ANALYTICS".to_string(), + value: Some(if analytics { "True" } else { "False" }.to_string()), + ..Default::default() + }); + } + + let init_image = instance + .spec + .config + .init_container_image + .clone() + .unwrap_or_else(|| "busybox:latest".to_string()); + + init_containers.push(Container { + name: "init-config".to_string(), + image: Some(init_image), // Busybox with sed (configurable) + command: Some(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + config_script.to_string(), + ]), + env: Some(init_env), + volume_mounts: Some(vec![VolumeMount { + name: "config".to_string(), + mount_path: "/config".to_string(), + ..Default::default() + }]), + ..Default::default() + }); + + // Add user-defined init container if specified + if let Some(ref init_config) = instance.spec.init_container { + let mut init_env = Vec::new(); + for env in &init_config.env { + if let Some(ref value) = env.value { + init_env.push(EnvVar { + name: env.name.clone(), + value: Some(value.clone()), + ..Default::default() + }); + } + } + + init_containers.push(Container { + name: "init-config".to_string(), + image: Some(init_config.image.clone()), + command: if init_config.command.is_empty() { + None + } else { + Some(init_config.command.clone()) + }, + args: if init_config.args.is_empty() { + None + } else { + Some(init_config.args.clone()) + }, + env: if init_env.is_empty() { + None + } else { + Some(init_env) + }, + volume_mounts: Some(vec![VolumeMount { + name: "config".to_string(), + mount_path: "/config".to_string(), + ..Default::default() + }]), + ..Default::default() + }); + } + + // Build main container + let container = Container { + name: "sonarr".to_string(), + image: Some(instance.spec.image.clone()), + image_pull_policy: Some(instance.spec.image_pull_policy.clone()), + ports: Some(vec![ContainerPort { + container_port: instance.container_port(), + name: Some("http".to_string()), + protocol: Some("TCP".to_string()), + ..Default::default() + }]), + env: Some(env_vars), + volume_mounts: Some(volume_mounts), + resources: instance + .spec + .resources + .as_ref() + .map(|r| ResourceRequirements { + limits: if r.limits.is_empty() { + None + } else { + Some( + r.limits + .iter() + .map(|(k, v)| (k.clone(), Quantity(v.clone()))) + .collect(), + ) + }, + requests: if r.requests.is_empty() { + None + } else { + Some( + r.requests + .iter() + .map(|(k, v)| (k.clone(), Quantity(v.clone()))) + .collect(), + ) + }, + ..Default::default() + }), + liveness_probe: Some(k8s_openapi::api::core::v1::Probe { + http_get: Some(k8s_openapi::api::core::v1::HTTPGetAction { + path: Some("/ping".to_string()), + port: k8s_openapi::apimachinery::pkg::util::intstr::IntOrString::Int( + instance.container_port(), + ), + ..Default::default() + }), + initial_delay_seconds: Some(60), + period_seconds: Some(30), + failure_threshold: Some(5), + ..Default::default() + }), + readiness_probe: Some(k8s_openapi::api::core::v1::Probe { + http_get: Some(k8s_openapi::api::core::v1::HTTPGetAction { + path: Some("/ping".to_string()), + port: k8s_openapi::apimachinery::pkg::util::intstr::IntOrString::Int( + instance.container_port(), + ), + ..Default::default() + }), + initial_delay_seconds: Some(10), + period_seconds: Some(10), + ..Default::default() + }), + ..Default::default() + }; + + // Build pod security context + let pod_security_context = instance.spec.security_context.as_ref().map(|sc| { + k8s_openapi::api::core::v1::PodSecurityContext { + run_as_user: sc.run_as_user, + run_as_group: sc.run_as_group, + fs_group: sc.fs_group, + run_as_non_root: sc.run_as_non_root, + ..Default::default() + } + }); + + // Build tolerations + let tolerations: Option> = + if instance.spec.tolerations.is_empty() { + None + } else { + Some( + instance + .spec + .tolerations + .iter() + .map(|t| k8s_openapi::api::core::v1::Toleration { + key: t.key.clone(), + operator: t.operator.clone(), + value: t.value.clone(), + effect: t.effect.clone(), + toleration_seconds: t.toleration_seconds, + }) + .collect(), + ) + }; + + let deployment = Deployment { + metadata: ObjectMeta { + name: Some(deployment_name.clone()), + namespace: Some(namespace.to_string()), + labels: Some(labels.clone()), + owner_references: Some(vec![owner_ref.clone()]), + ..Default::default() + }, + spec: Some(DeploymentSpec { + replicas: Some(instance.spec.replicas), + selector: LabelSelector { + match_labels: Some(labels.clone()), + ..Default::default() + }, + template: PodTemplateSpec { + metadata: Some(ObjectMeta { + labels: Some(labels.clone()), + ..Default::default() + }), + spec: Some(PodSpec { + init_containers: if init_containers.is_empty() { + None + } else { + Some(init_containers) + }, + containers: vec![container], + volumes: Some(volumes), + node_selector: if instance.spec.node_selector.is_empty() { + None + } else { + Some(instance.spec.node_selector.clone()) + }, + tolerations, + security_context: pod_security_context, + ..Default::default() + }), + }, + ..Default::default() + }), + ..Default::default() + }; + + Ok(deployment) +} + +async fn reconcile_service( + client: &Client, + namespace: &str, + instance: &Sonarr, + owner_ref: &OwnerReference, + labels: &BTreeMap, +) -> Result<()> { + let service_name = instance.service_name(); + let service_api: Api = Api::namespaced(client.clone(), namespace); + + let service = Service { + metadata: ObjectMeta { + name: Some(service_name.clone()), + namespace: Some(namespace.to_string()), + labels: Some(labels.clone()), + annotations: if instance.spec.service.annotations.is_empty() { + None + } else { + Some(instance.spec.service.annotations.clone()) + }, + owner_references: Some(vec![owner_ref.clone()]), + ..Default::default() + }, + spec: Some(ServiceSpec { + type_: Some(instance.service_type()), + selector: Some(labels.clone()), + ports: Some(vec![ServicePort { + name: Some("http".to_string()), + port: instance.service_port(), + target_port: Some( + k8s_openapi::apimachinery::pkg::util::intstr::IntOrString::Int( + instance.container_port(), + ), + ), + node_port: instance.spec.service.node_port, + protocol: Some("TCP".to_string()), + ..Default::default() + }]), + ..Default::default() + }), + ..Default::default() + }; + + let patch_params = PatchParams::apply("sonarr-operator").force(); + service_api + .patch(&service_name, &patch_params, &Patch::Apply(&service)) + .await + .map_err(Error::KubeError)?; + + info!("Service {} applied", service_name); + + Ok(()) +} + +async fn reconcile_ingress( + client: &Client, + namespace: &str, + instance: &Sonarr, + ingress_config: &crate::crds::sonarr::IngressConfig, + owner_ref: &OwnerReference, + labels: &BTreeMap, +) -> Result<()> { + let ingress_name = format!("{}-sonarr", instance.name_any()); + let ingress_api: Api = Api::namespaced(client.clone(), namespace); + + let tls = ingress_config.tls.as_ref().map(|tls_config| { + vec![IngressTLS { + hosts: Some(if tls_config.hosts.is_empty() { + vec![ingress_config.host.clone()] + } else { + tls_config.hosts.clone() + }), + secret_name: Some(tls_config.secret_name.clone()), + }] + }); + + let ingress = Ingress { + metadata: ObjectMeta { + name: Some(ingress_name.clone()), + namespace: Some(namespace.to_string()), + labels: Some(labels.clone()), + annotations: if ingress_config.annotations.is_empty() { + None + } else { + Some(ingress_config.annotations.clone()) + }, + owner_references: Some(vec![owner_ref.clone()]), + ..Default::default() + }, + spec: Some(IngressSpec { + ingress_class_name: ingress_config.ingress_class_name.clone(), + tls, + rules: Some(vec![IngressRule { + host: Some(ingress_config.host.clone()), + http: Some(HTTPIngressRuleValue { + paths: vec![HTTPIngressPath { + path: Some(ingress_config.path.clone()), + path_type: ingress_config.path_type.clone(), + backend: IngressBackend { + service: Some(IngressServiceBackend { + name: instance.service_name(), + port: Some(ServiceBackendPort { + number: Some(instance.service_port()), + ..Default::default() + }), + }), + ..Default::default() + }, + }], + }), + }]), + ..Default::default() + }), + ..Default::default() + }; + + let patch_params = PatchParams::apply("sonarr-operator").force(); + ingress_api + .patch(&ingress_name, &patch_params, &Patch::Apply(&ingress)) + .await + .map_err(Error::KubeError)?; + + info!("Ingress {} applied", ingress_name); + + Ok(()) +} + +async fn reconcile_http_route( + client: &Client, + namespace: &str, + instance: &Sonarr, + http_route_config: &crate::crds::sonarr::HTTPRouteConfig, + owner_ref: &OwnerReference, + labels: &BTreeMap, +) -> Result<()> { + use kube::api::DynamicObject; + use kube::discovery::ApiResource; + + let route_name = format!("{}-sonarr", instance.name_any()); + + // Build the HTTPRoute as a DynamicObject since gateway.networking.k8s.io types + // are not in k8s-openapi yet + let api_resource = ApiResource { + group: "gateway.networking.k8s.io".to_string(), + version: "v1".to_string(), + kind: "HTTPRoute".to_string(), + api_version: "gateway.networking.k8s.io/v1".to_string(), + plural: "httproutes".to_string(), + }; + + let route_api: Api = + Api::namespaced_with(client.clone(), namespace, &api_resource); + + // Merge labels + let mut route_labels = labels.clone(); + for (k, v) in &http_route_config.labels { + route_labels.insert(k.clone(), v.clone()); + } + + // Build parent reference + let mut parent_ref = serde_json::json!({ + "group": "gateway.networking.k8s.io", + "kind": "Gateway", + "name": http_route_config.gateway_ref.name + }); + + if let Some(ref ns) = http_route_config.gateway_ref.namespace { + parent_ref["namespace"] = serde_json::json!(ns); + } + if let Some(ref section) = http_route_config.gateway_ref.section_name { + parent_ref["sectionName"] = serde_json::json!(section); + } + + // Build path match + let path_match = serde_json::json!({ + "type": http_route_config.path_type, + "value": http_route_config.path + }); + + // Build backend ref + let backend_ref = serde_json::json!({ + "kind": "Service", + "name": instance.service_name(), + "port": instance.service_port() + }); + + // Build the HTTPRoute spec + let http_route_spec = serde_json::json!({ + "parentRefs": [parent_ref], + "hostnames": if http_route_config.hostnames.is_empty() { None } else { Some(&http_route_config.hostnames) }, + "rules": [{ + "matches": [{ + "path": path_match + }], + "backendRefs": [backend_ref] + }] + }); + + // Build the full HTTPRoute object + let http_route = serde_json::json!({ + "apiVersion": "gateway.networking.k8s.io/v1", + "kind": "HTTPRoute", + "metadata": { + "name": route_name, + "namespace": namespace, + "labels": route_labels, + "annotations": if http_route_config.annotations.is_empty() { None } else { Some(&http_route_config.annotations) }, + "ownerReferences": [owner_ref] + }, + "spec": http_route_spec + }); + + let patch_params = PatchParams::apply("sonarr-operator").force(); + route_api + .patch(&route_name, &patch_params, &Patch::Apply(&http_route)) + .await + .map_err(Error::KubeError)?; + + info!("HTTPRoute {} applied", route_name); + + Ok(()) +} + +async fn update_status(client: &Client, namespace: &str, instance: &Sonarr) -> Result<()> { + let instances: Api = Api::namespaced(client.clone(), namespace); + let name = instance.name_any(); + + // Check deployment status + let deployment_api: Api = Api::namespaced(client.clone(), namespace); + let deployment_name = instance.deployment_name(); + + let (ready, ready_replicas) = match deployment_api.get_opt(&deployment_name).await? { + Some(deployment) => { + let status = deployment.status.unwrap_or_default(); + let ready = status.ready_replicas.unwrap_or(0); + let desired = status.replicas.unwrap_or(0); + (ready >= desired && desired > 0, ready) + } + None => (false, 0), + }; + + // Build URL + let url = if let Some(ref ingress) = instance.spec.ingress { + if ingress.enabled { + let scheme = if ingress.tls.is_some() { + "https" + } else { + "http" + }; + Some(format!("{}://{}{}", scheme, ingress.host, ingress.path)) + } else { + Some(instance.internal_url(namespace)) + } + } else { + Some(instance.internal_url(namespace)) + }; + + // Build conditions + let mut conditions = instance + .status + .as_ref() + .map(|s| s.conditions.clone()) + .unwrap_or_default(); + + if ready { + update_conditions( + &mut conditions, + ready_condition(true, "Ready", "Sonarr instance is ready"), + ); + } else { + update_conditions( + &mut conditions, + ready_condition(false, "NotReady", "Sonarr instance is not ready"), + ); + update_conditions( + &mut conditions, + progressing_condition(true, "Deploying", "Deployment is in progress"), + ); + } + + // Get API key secret name + let api_key_secret = if instance.spec.api_key_secret_ref.is_some() { + instance + .spec + .api_key_secret_ref + .as_ref() + .map(|s| s.name.clone()) + } else { + Some(instance.api_key_secret_name()) + }; + + let status = SonarrStatus { + conditions, + url, + api_key_secret, + observed_generation: instance.metadata.generation.unwrap_or(0), + ready_replicas, + version: None, // TODO: Get from Sonarr API + }; + + let status_patch = serde_json::json!({ + "status": status + }); + + instances + .patch_status(&name, &PatchParams::default(), &Patch::Merge(&status_patch)) + .await + .map_err(Error::KubeError)?; + + Ok(()) +} diff --git a/src/controllers/tag.rs b/src/controllers/tag.rs new file mode 100644 index 0000000..f39a012 --- /dev/null +++ b/src/controllers/tag.rs @@ -0,0 +1,187 @@ +use std::sync::Arc; + +use k8s_openapi::api::core::v1::Secret; +use kube::api::{Api, Patch, PatchParams}; +use kube::runtime::controller::Action; +use kube::{Client, ResourceExt}; +use tracing::info; + +use sonarr::apis::configuration::Configuration; +use sonarr::apis::tag_api; +use sonarr::models::TagResource; + +use crate::Context; +use crate::crds::{Sonarr, SonarrInstanceRef, SonarrTag, SonarrTagStatus}; +use crate::error::{Error, Result}; + +use super::traits::{REQUEUE_DURATION, reconcile_with_finalizer, run_controller}; +use super::{ready_condition, update_conditions}; + +/// Start the SonarrTag controller +pub async fn run(client: Client, context: Arc) { + run_controller::(client, context, "SonarrTag", reconcile).await; +} + +async fn reconcile(obj: Arc, ctx: Arc) -> Result { + reconcile_with_finalizer(obj, ctx, reconcile_apply, reconcile_cleanup).await +} + +async fn reconcile_apply(tag: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = tag + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + let name = tag.name_any(); + + info!("Reconciling SonarrTag: {}/{}", namespace, name); + + // Get the Sonarr configuration + let config = get_sonarr_config(&ctx, client, &namespace, &tag.spec.sonarr_instance_ref).await?; + + // Get or create the tag in Sonarr + let sonarr_tag = if let Some(id) = tag.status.as_ref().and_then(|s| s.id) { + // Update existing tag + let mut tag_resource = TagResource::new(); + tag_resource.id = Some(id); + tag_resource.label = Some(Some(tag.spec.label.clone())); + + match tag_api::update_tag(&config, &id.to_string(), Some(tag_resource.clone())).await { + Ok(t) => t, + Err(_) => { + // Tag might have been deleted, create a new one + let mut new_tag = TagResource::new(); + new_tag.label = Some(Some(tag.spec.label.clone())); + tag_api::create_tag(&config, Some(new_tag)).await? + } + } + } else { + // Check if tag already exists + let existing_tags = tag_api::list_tag(&config).await?; + if let Some(existing) = existing_tags + .iter() + .find(|t| t.label.as_ref().and_then(|l| l.as_ref()) == Some(&tag.spec.label)) + { + existing.clone() + } else { + let mut new_tag = TagResource::new(); + new_tag.label = Some(Some(tag.spec.label.clone())); + tag_api::create_tag(&config, Some(new_tag)).await? + } + }; + + // Update status + let tags_api: Api = Api::namespaced(client.clone(), &namespace); + let mut conditions = tag + .status + .as_ref() + .map(|s| s.conditions.clone()) + .unwrap_or_default(); + update_conditions( + &mut conditions, + ready_condition(true, "Synced", "Tag synchronized with Sonarr"), + ); + + let status = SonarrTagStatus { + conditions, + id: sonarr_tag.id, + observed_generation: tag.metadata.generation.unwrap_or(0), + }; + + let status_patch = serde_json::json!({ "status": status }); + tags_api + .patch_status(&name, &PatchParams::default(), &Patch::Merge(&status_patch)) + .await?; + + Ok(Action::requeue(REQUEUE_DURATION)) +} + +async fn reconcile_cleanup(tag: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = tag + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + + info!("Cleaning up SonarrTag: {}/{}", namespace, tag.name_any()); + + if let Some(id) = tag.status.as_ref().and_then(|s| s.id) { + if let Ok(config) = + get_sonarr_config(&ctx, client, &namespace, &tag.spec.sonarr_instance_ref).await + { + let _ = tag_api::delete_tag(&config, id).await; + } + } + + Ok(Action::await_change()) +} + +pub async fn get_sonarr_config( + ctx: &Context, + client: &Client, + namespace: &str, + instance_ref: &SonarrInstanceRef, +) -> Result> { + let instance_namespace = instance_ref.namespace.as_deref().unwrap_or(namespace); + let instances: Api = Api::namespaced(client.clone(), instance_namespace); + + let instance = instances + .get(&instance_ref.name) + .await + .map_err(|_| Error::SonarrInstanceNotFound(instance_ref.name.clone()))?; + + // Check if instance is ready + let is_ready = instance + .status + .as_ref() + .map(|s| { + s.conditions + .iter() + .any(|c| c.type_ == "Ready" && c.status == "True") + }) + .unwrap_or(false); + + if !is_ready { + return Err(Error::SonarrInstanceNotReady(instance_ref.name.clone())); + } + + // Get URL + let url = instance + .status + .as_ref() + .and_then(|s| s.url.clone()) + .ok_or(Error::SonarrInstanceNotReady(instance_ref.name.clone()))?; + + // Get API key + let api_key = get_api_key(client, instance_namespace, &instance).await?; + + let instance_key = format!("{}/{}", instance_namespace, instance_ref.name); + Ok(ctx + .sonarr_client_factory + .get_config(&url, &api_key, &instance_key) + .await) +} + +pub async fn get_api_key(client: &Client, namespace: &str, instance: &Sonarr) -> Result { + let secret_name = if let Some(ref secret_ref) = instance.spec.api_key_secret_ref { + secret_ref.name.clone() + } else { + instance.api_key_secret_name() + }; + + let secret_key = instance + .spec + .api_key_secret_ref + .as_ref() + .map(|s| s.key.clone()) + .unwrap_or_else(|| "api-key".to_string()); + + let secrets: Api = Api::namespaced(client.clone(), namespace); + let secret = secrets + .get(&secret_name) + .await + .map_err(|_| Error::MissingApiCredentials)?; + + let data = secret.data.ok_or(Error::MissingApiCredentials)?; + let api_key_bytes = data.get(&secret_key).ok_or(Error::MissingApiCredentials)?; + + String::from_utf8(api_key_bytes.0.clone()).map_err(|_| Error::MissingApiCredentials) +} diff --git a/src/controllers/traits.rs b/src/controllers/traits.rs new file mode 100644 index 0000000..3de6d08 --- /dev/null +++ b/src/controllers/traits.rs @@ -0,0 +1,200 @@ +//! Common traits and utilities for Sonarr sub-resource controllers +//! +//! This module provides: +//! - Generic controller run function with built-in finalizer support +//! - Generic error policy +//! - Common status update functions +//! - Shared utilities to reduce boilerplate across controllers + +use std::fmt::Debug; +use std::future::Future; +use std::hash::Hash; +use std::sync::Arc; +use std::time::Duration; + +use futures::StreamExt; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; +use k8s_openapi::NamespaceResourceScope; +use kube::api::{Api, Patch, PatchParams}; +use kube::runtime::controller::{Action, Controller}; +use kube::runtime::finalizer::{finalizer, Event}; +use kube::runtime::watcher; +use kube::{Client, Resource, ResourceExt}; +use serde::{de::DeserializeOwned, Serialize}; +use serde_json::json; +use tracing::{debug, error, info}; + +use crate::crds::{SonarrInstanceRef, FINALIZER}; +use crate::error::{Error, Result}; +use crate::Context; + +use super::{ready_condition, update_conditions}; + +// Re-export the get_sonarr_config from tag module as the canonical implementation +pub use super::tag::get_sonarr_config; + +/// Trait for resource specs that reference a Sonarr instance +pub trait HasSonarrInstanceRef { + fn sonarr_instance_ref(&self) -> &SonarrInstanceRef; +} + +/// Generic error policy for all controllers +pub fn error_policy(resource_name: &'static str) -> impl Fn(Arc, &Error, Arc) -> Action +where + R: Resource + ResourceExt, +{ + move |obj: Arc, err: &Error, _ctx: Arc| { + error!("Error reconciling {} {}: {:?}", resource_name, obj.name_any(), err); + Action::requeue(Duration::from_secs(60)) + } +} + +/// Wrapper that handles the common finalizer pattern for sub-resources +/// This eliminates boilerplate from individual controllers by wrapping +/// the kube-rs finalizer function with our standard error handling +pub async fn reconcile_with_finalizer( + obj: Arc, + ctx: Arc, + apply_fn: ApplyFn, + cleanup_fn: CleanupFn, +) -> Result +where + R: Resource + + Clone + + Debug + + DeserializeOwned + + Serialize + + Send + + Sync + + 'static, + ApplyFn: FnOnce(Arc, Arc) -> ApplyFut, + ApplyFut: Future> + Send, + CleanupFn: FnOnce(Arc, Arc) -> CleanupFut, + CleanupFut: Future> + Send, +{ + let client = &ctx.client; + let namespace = obj + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + + let api: Api = Api::namespaced(client.clone(), &namespace); + + finalizer(&api, FINALIZER, obj.clone(), |event| async { + match event { + Event::Apply(resource) => apply_fn(resource, ctx.clone()).await, + Event::Cleanup(resource) => cleanup_fn(resource, ctx.clone()).await, + } + }) + .await + .map_err(|e| Error::FinalizerError(Box::new(e))) +} + +/// Start a generic controller for a Sonarr sub-resource +pub async fn run_controller( + client: Client, + context: Arc, + resource_name: &'static str, + reconcile_fn: ReconcileFn, +) where + R: Resource + + Clone + + Debug + + DeserializeOwned + + Send + + Sync + + 'static, + R::DynamicType: Default + Eq + Hash + Clone, + ReconcileFn: FnMut(Arc, Arc) -> ReconcileFut + Send + Sync + 'static + Clone, + ReconcileFut: Future> + Send + 'static, +{ + let resources = Api::::all(client.clone()); + + info!("Starting {} controller", resource_name); + + let error_handler = error_policy::(resource_name); + + Controller::new(resources, watcher::Config::default()) + .shutdown_on_signal() + .run(reconcile_fn, error_handler, context) + .for_each(|res| async move { + match res { + Ok(o) => debug!("Reconciled {}: {:?}", resource_name, o), + Err(e) => error!("Reconcile error: {:?}", e), + } + }) + .await; +} + +/// Update the status of a Sonarr resource with success +pub async fn update_status_success( + client: &Client, + namespace: &str, + name: &str, + sonarr_id: i32, + generation: i64, + existing_conditions: Vec, +) -> Result<()> +where + R: Resource + Clone + Debug + DeserializeOwned + Serialize, + R: Resource, +{ + let api: Api = Api::namespaced(client.clone(), namespace); + let mut conditions = existing_conditions; + update_conditions( + &mut conditions, + ready_condition(true, "Synced", "Resource synced with Sonarr"), + ); + + let status = json!({ + "status": { + "conditions": conditions, + "id": sonarr_id, + "observedGeneration": generation + } + }); + + api.patch_status(name, &PatchParams::apply("sonarr-operator"), &Patch::Merge(&status)) + .await + .map_err(|e| Error::KubeError(e))?; + + info!("Updated {} {}/{} status with id={}", std::any::type_name::(), namespace, name, sonarr_id); + Ok(()) +} + +/// Update the status of a Sonarr resource with failure +pub async fn update_status_failure( + client: &Client, + namespace: &str, + name: &str, + error_message: &str, + existing_conditions: Vec, +) -> Result<()> +where + R: Resource + Clone + Debug + DeserializeOwned + Serialize, + R: Resource, +{ + let api: Api = Api::namespaced(client.clone(), namespace); + let mut conditions = existing_conditions; + update_conditions( + &mut conditions, + ready_condition(false, "Error", error_message), + ); + + let status = json!({ + "status": { + "conditions": conditions + } + }); + + api.patch_status(name, &PatchParams::apply("sonarr-operator"), &Patch::Merge(&status)) + .await + .map_err(|e| Error::KubeError(e))?; + + Ok(()) +} + +/// Common requeue duration for successful reconciliation +pub const REQUEUE_DURATION: Duration = Duration::from_secs(300); + +/// Common requeue duration for errors +pub const ERROR_REQUEUE_DURATION: Duration = Duration::from_secs(60); diff --git a/src/controllers/utils.rs b/src/controllers/utils.rs new file mode 100644 index 0000000..1ed90da --- /dev/null +++ b/src/controllers/utils.rs @@ -0,0 +1,54 @@ +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; + +fn now() -> k8s_openapi::apimachinery::pkg::apis::meta::v1::Time { + k8s_openapi::apimachinery::pkg::apis::meta::v1::Time( + k8s_openapi::jiff::Timestamp::now() + ) +} + +/// Create a Ready condition with the given status and message +pub fn ready_condition(status: bool, reason: &str, message: &str) -> Condition { + Condition { + type_: "Ready".to_string(), + status: if status { "True" } else { "False" }.to_string(), + reason: reason.to_string(), + message: message.to_string(), + last_transition_time: now(), + observed_generation: None, + } +} + +/// Create a Progressing condition +pub fn progressing_condition(status: bool, reason: &str, message: &str) -> Condition { + Condition { + type_: "Progressing".to_string(), + status: if status { "True" } else { "False" }.to_string(), + reason: reason.to_string(), + message: message.to_string(), + last_transition_time: now(), + observed_generation: None, + } +} + +/// Create a Synced condition +pub fn synced_condition(status: bool, reason: &str, message: &str) -> Condition { + Condition { + type_: "Synced".to_string(), + status: if status { "True" } else { "False" }.to_string(), + reason: reason.to_string(), + message: message.to_string(), + last_transition_time: now(), + observed_generation: None, + } +} + +/// Update conditions list, replacing existing conditions of the same type +pub fn update_conditions(conditions: &mut Vec, new_condition: Condition) { + if let Some(existing) = conditions.iter_mut().find(|c| c.type_ == new_condition.type_) { + if existing.status != new_condition.status || existing.reason != new_condition.reason { + *existing = new_condition; + } + } else { + conditions.push(new_condition); + } +} diff --git a/src/crd/mod.rs b/src/crd/mod.rs deleted file mode 100644 index 76cf03c..0000000 --- a/src/crd/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod sonarr; diff --git a/src/crd/sonarr.rs b/src/crd/sonarr.rs deleted file mode 100644 index cd9eb26..0000000 --- a/src/crd/sonarr.rs +++ /dev/null @@ -1,40 +0,0 @@ -use kube::CustomResource; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; - -/// Define the Custom Resource Definition (CRD) for our operator -#[derive(CustomResource, Serialize, Deserialize, Default, Debug, Clone, JsonSchema)] -#[kube( - group = "devopsarr.io", - version = "v1", - kind = "Sonarr", - plural = "sonarrs", - shortname = "son", - namespaced -)] -#[kube(status = "SonarrStatus")] -#[serde(rename_all = "camelCase")] -pub struct SonarrSpec { - pub message: String, - pub replica_count: Option, //TODO: found a way to write compliant with code style -} - -/// Status object for our CRD -#[derive(Serialize, Deserialize, Default, Debug, Clone, JsonSchema)] -pub struct SonarrStatus { - // pub created_at: Option>, - pub conditions: Vec, - pub observed_generation: Option, - pub ready: Option, -} - -/// Condition type for tracking the status of the resource -#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema)] -pub struct SonarrCondition { - #[serde(rename = "type")] - pub condition_type: String, - pub status: String, - //pub last_transition_time: Time, - pub reason: String, - pub message: String, -} diff --git a/src/crds/auto_tag.rs b/src/crds/auto_tag.rs new file mode 100644 index 0000000..345220c --- /dev/null +++ b/src/crds/auto_tag.rs @@ -0,0 +1,124 @@ +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::SonarrInstanceRef; + +/// SonarrAutoTag represents an auto-tagging rule configuration in Sonarr +/// Auto-tagging automatically applies tags to series based on conditions +#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[kube( + group = "devopsarr.io", + version = "v1alpha1", + kind = "SonarrAutoTag", + plural = "sonarrautotags", + shortname = "sat", + namespaced, + status = "SonarrAutoTagStatus", + printcolumn = r#"{"name":"Name","type":"string","jsonPath":".spec.name"}"#, + printcolumn = r#"{"name":"RemoveAuto","type":"boolean","jsonPath":".spec.removeTagsAutomatically"}"#, + printcolumn = r#"{"name":"ID","type":"integer","jsonPath":".status.id"}"#, + printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type==\"Ready\")].status"}"#, + printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"# +)] +#[serde(rename_all = "camelCase")] +pub struct SonarrAutoTagSpec { + /// Reference to the SonarrInstance + pub sonarr_instance_ref: SonarrInstanceRef, + + /// Auto-tag rule name + pub name: String, + + /// Remove tags automatically when conditions no longer match + #[serde(default)] + pub remove_tags_automatically: bool, + + /// Tags to apply when conditions match + #[serde(default)] + pub tags: Vec, + + /// Specifications (conditions) for this auto-tag rule + #[serde(default)] + pub specifications: Vec, +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct AutoTagSpecification { + /// Specification name + pub name: String, + + /// Specification type/implementation + pub implementation: AutoTagImplementation, + + /// Negate this condition + #[serde(default)] + pub negate: bool, + + /// This condition is required + #[serde(default = "default_true")] + pub required: bool, + + /// Fields/values for this specification + #[serde(default)] + pub fields: AutoTagFields, +} + +fn default_true() -> bool { + true +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub enum AutoTagImplementation { + /// Root folder path matches + #[default] + RootFolderSpecification, + /// Genre matches + GenreSpecification, + /// Year matches + YearSpecification, + /// Series type matches + SeriesTypeSpecification, + /// Quality profile matches + QualityProfileSpecification, + /// Network matches + NetworkSpecification, + /// Original language matches + OriginalLanguageSpecification, + /// Tags match + TagSpecification, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct AutoTagFields { + /// Value for the specification (path, genre, network, etc.) + #[serde(default)] + pub value: Option, + + /// Minimum value (for year specifications) + #[serde(default)] + pub min: Option, + + /// Maximum value (for year specifications) + #[serde(default)] + pub max: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SonarrAutoTagStatus { + /// Current conditions + #[serde(default)] + pub conditions: Vec, + + /// Sonarr Auto Tag ID + #[serde(default)] + pub id: Option, + + /// Observed generation + #[serde(default)] + pub observed_generation: i64, +} diff --git a/src/crds/custom_format.rs b/src/crds/custom_format.rs new file mode 100644 index 0000000..d4990e8 --- /dev/null +++ b/src/crds/custom_format.rs @@ -0,0 +1,121 @@ +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::SonarrInstanceRef; + +/// SonarrCustomFormat represents a custom format configuration in Sonarr +/// Custom formats are used to score releases based on various criteria +#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[kube( + group = "devopsarr.io", + version = "v1alpha1", + kind = "SonarrCustomFormat", + plural = "sonarrcustomformats", + shortname = "scf", + namespaced, + status = "SonarrCustomFormatStatus", + printcolumn = r#"{"name":"Name","type":"string","jsonPath":".spec.name"}"#, + printcolumn = r#"{"name":"ID","type":"integer","jsonPath":".status.id"}"#, + printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type==\"Ready\")].status"}"#, + printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"# +)] +#[serde(rename_all = "camelCase")] +pub struct SonarrCustomFormatSpec { + /// Reference to the SonarrInstance + pub sonarr_instance_ref: SonarrInstanceRef, + + /// Custom format name + pub name: String, + + /// Include custom format name when renaming files + #[serde(default)] + pub include_custom_format_when_renaming: bool, + + /// Specifications (conditions) for this custom format + #[serde(default)] + pub specifications: Vec, +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct CustomFormatSpecification { + /// Specification name + pub name: String, + + /// Specification type/implementation + pub implementation: CustomFormatImplementation, + + /// Negate this condition + #[serde(default)] + pub negate: bool, + + /// This condition is required + #[serde(default = "default_true")] + pub required: bool, + + /// Fields/values for this specification + #[serde(default)] + pub fields: CustomFormatFields, +} + +fn default_true() -> bool { + true +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub enum CustomFormatImplementation { + /// Release title matches regex + #[default] + ReleaseTitleSpecification, + /// Source matches + SourceSpecification, + /// Resolution matches + ResolutionSpecification, + /// Quality modifier matches + QualityModifierSpecification, + /// Size specification + SizeSpecification, + /// Indexer flag + IndexerFlagSpecification, + /// Language matches + LanguageSpecification, + /// Release group matches + ReleaseGroupSpecification, + /// Edition matches + EditionSpecification, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct CustomFormatFields { + /// Value for the specification (regex pattern, source type, etc.) + #[serde(default)] + pub value: Option, + + /// Minimum value (for size specifications) + #[serde(default)] + pub min: Option, + + /// Maximum value (for size specifications) + #[serde(default)] + pub max: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SonarrCustomFormatStatus { + /// Current conditions + #[serde(default)] + pub conditions: Vec, + + /// Sonarr Custom Format ID + #[serde(default)] + pub id: Option, + + /// Observed generation + #[serde(default)] + pub observed_generation: i64, +} diff --git a/src/crds/delay_profile.rs b/src/crds/delay_profile.rs new file mode 100644 index 0000000..7c5b5b7 --- /dev/null +++ b/src/crds/delay_profile.rs @@ -0,0 +1,98 @@ +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::SonarrInstanceRef; + +/// SonarrDelayProfile represents a delay profile configuration in Sonarr +/// Delay profiles control how long Sonarr waits before grabbing a release +#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[kube( + group = "devopsarr.io", + version = "v1alpha1", + kind = "SonarrDelayProfile", + plural = "sonarrdelayprofiles", + shortname = "sdp", + namespaced, + status = "SonarrDelayProfileStatus", + printcolumn = r#"{"name":"Protocol","type":"string","jsonPath":".spec.preferredProtocol"}"#, + printcolumn = r#"{"name":"UsenetDelay","type":"integer","jsonPath":".spec.usenetDelay"}"#, + printcolumn = r#"{"name":"TorrentDelay","type":"integer","jsonPath":".spec.torrentDelay"}"#, + printcolumn = r#"{"name":"ID","type":"integer","jsonPath":".status.id"}"#, + printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type==\"Ready\")].status"}"#, + printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"# +)] +#[serde(rename_all = "camelCase")] +pub struct SonarrDelayProfileSpec { + /// Reference to the SonarrInstance + pub sonarr_instance_ref: SonarrInstanceRef, + + /// Enable Usenet downloads + #[serde(default = "default_true")] + pub enable_usenet: bool, + + /// Enable Torrent downloads + #[serde(default = "default_true")] + pub enable_torrent: bool, + + /// Preferred download protocol + #[serde(default)] + pub preferred_protocol: DownloadProtocol, + + /// Delay for Usenet in minutes + #[serde(default)] + pub usenet_delay: i32, + + /// Delay for Torrents in minutes + #[serde(default)] + pub torrent_delay: i32, + + /// Bypass delay if highest quality + #[serde(default)] + pub bypass_if_highest_quality: bool, + + /// Bypass delay if above custom format score + #[serde(default)] + pub bypass_if_above_custom_format_score: bool, + + /// Minimum custom format score to bypass delay + #[serde(default)] + pub minimum_custom_format_score: i32, + + /// Order of this profile (lower = higher priority) + #[serde(default)] + pub order: i32, + + /// Tags to apply this delay profile to + #[serde(default)] + pub tags: Vec, +} + +fn default_true() -> bool { + true +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub enum DownloadProtocol { + #[default] + Usenet, + Torrent, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SonarrDelayProfileStatus { + /// Current conditions + #[serde(default)] + pub conditions: Vec, + + /// Sonarr Delay Profile ID + #[serde(default)] + pub id: Option, + + /// Observed generation + #[serde(default)] + pub observed_generation: i64, +} diff --git a/src/crds/download_client.rs b/src/crds/download_client.rs new file mode 100644 index 0000000..a24730b --- /dev/null +++ b/src/crds/download_client.rs @@ -0,0 +1,203 @@ +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::SecretKeySelector; +use super::SonarrInstanceRef; + +/// SonarrDownloadClient represents a download client configuration in Sonarr +/// Download clients are used to download releases (qBittorrent, Transmission, SABnzbd, etc.) +#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[kube( + group = "devopsarr.io", + version = "v1alpha1", + kind = "SonarrDownloadClient", + plural = "sonarrdownloadclients", + shortname = "sdc", + namespaced, + status = "SonarrDownloadClientStatus", + printcolumn = r#"{"name":"Name","type":"string","jsonPath":".spec.name"}"#, + printcolumn = r#"{"name":"Type","type":"string","jsonPath":".spec.downloadClientType"}"#, + printcolumn = r#"{"name":"ID","type":"integer","jsonPath":".status.id"}"#, + printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type==\"Ready\")].status"}"#, + printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"# +)] +#[serde(rename_all = "camelCase")] +pub struct SonarrDownloadClientSpec { + /// Reference to the SonarrInstance + pub sonarr_instance_ref: SonarrInstanceRef, + + /// Download client name + pub name: String, + + /// Enable this download client + #[serde(default = "default_true")] + pub enable: bool, + + /// Download client type + pub download_client_type: DownloadClientType, + + /// Priority for this download client + #[serde(default = "default_priority")] + pub priority: i32, + + /// Remove completed downloads + #[serde(default = "default_true")] + pub remove_completed_downloads: bool, + + /// Remove failed downloads + #[serde(default = "default_true")] + pub remove_failed_downloads: bool, + + /// Tags for this download client + #[serde(default)] + pub tags: Vec, + + /// Download client configuration + pub config: DownloadClientConfig, +} + +fn default_true() -> bool { + true +} + +fn default_priority() -> i32 { + 1 +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "PascalCase")] +pub enum DownloadClientType { + Aria2, + Deluge, + Flood, + Hadouken, + #[serde(rename = "Nzbget")] + NzbGet, + #[serde(rename = "Nzbvortex")] + NzbVortex, + Pneumatic, + #[serde(rename = "QBittorrent")] + QBittorrent, + #[serde(rename = "RTorrent")] + RTorrent, + #[serde(rename = "Sabnzbd")] + SABnzbd, + TorrentBlackhole, + TorrentDownloadStation, + Transmission, + UsenetBlackhole, + UsenetDownloadStation, + #[serde(rename = "UTorrent")] + UTorrent, + Vuze, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct DownloadClientConfig { + /// Host address + #[serde(default)] + pub host: Option, + + /// Port number + #[serde(default)] + pub port: Option, + + /// Use SSL + #[serde(default)] + pub use_ssl: bool, + + /// URL base path + #[serde(default)] + pub url_base: Option, + + /// Username + #[serde(default)] + pub username: Option, + + /// Password from secret + #[serde(default)] + pub password_secret_ref: Option, + + /// API key from secret (for some clients) + #[serde(default)] + pub api_key_secret_ref: Option, + + /// TV category + #[serde(default)] + pub tv_category: Option, + + /// TV directory + #[serde(default)] + pub tv_directory: Option, + + /// Recent TV priority (0 = Last, 1 = First) + #[serde(default)] + pub recent_tv_priority: Option, + + /// Older TV priority (0 = Last, 1 = First) + #[serde(default)] + pub older_tv_priority: Option, + + /// Add paused + #[serde(default)] + pub add_paused: bool, + + /// Save magnet files (for blackhole) + #[serde(default)] + pub save_magnet_files: bool, + + /// Watch folder (for blackhole) + #[serde(default)] + pub watch_folder: Option, + + /// Torrent folder (for blackhole) + #[serde(default)] + pub torrent_folder: Option, + + /// NZB folder (for blackhole) + #[serde(default)] + pub nzb_folder: Option, + + /// Strm folder (for pneumatic) + #[serde(default)] + pub strm_folder: Option, + + /// Secret token (for Aria2) + #[serde(default)] + pub secret_token_secret_ref: Option, + + /// RPC path (for Aria2) + #[serde(default)] + pub rpc_path: Option, + + /// Initial state (for qBittorrent: 0 = Start, 1 = ForceStart, 2 = Pause) + #[serde(default)] + pub initial_state: Option, + + /// Sequential order (for qBittorrent) + #[serde(default)] + pub sequential_order: bool, + + /// First and last (for qBittorrent) + #[serde(default)] + pub first_and_last: bool, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SonarrDownloadClientStatus { + /// Current conditions + #[serde(default)] + pub conditions: Vec, + + /// Sonarr Download Client ID + #[serde(default)] + pub id: Option, + + /// Observed generation + #[serde(default)] + pub observed_generation: i64, +} diff --git a/src/crds/download_client_config.rs b/src/crds/download_client_config.rs new file mode 100644 index 0000000..a749093 --- /dev/null +++ b/src/crds/download_client_config.rs @@ -0,0 +1,62 @@ +//! SonarrDownloadClientConfig CRD +//! +//! Configures global download client settings for a Sonarr instance. +//! Only one resource per Sonarr instance is allowed. + +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::SonarrInstanceRef; + +/// SonarrDownloadClientConfig configures global download client settings for a Sonarr instance. +/// Only one SonarrDownloadClientConfig per Sonarr instance is allowed. +/// Note: This is different from SonarrDownloadClient which configures individual download clients. +#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema, Default)] +#[kube( + group = "devopsarr.io", + version = "v1alpha1", + kind = "SonarrDownloadClientConfig", + plural = "sonarrdownloadclientconfigs", + shortname = "sdcc", + namespaced, + status = "SonarrDownloadClientConfigStatus", + printcolumn = r#"{"name":"Instance","type":"string","jsonPath":".spec.sonarrInstanceRef.name"}"#, + printcolumn = r#"{"name":"Completed Handling","type":"boolean","jsonPath":".spec.enableCompletedDownloadHandling"}"#, + printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type==\"Ready\")].status"}"#, + printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"# +)] +#[serde(rename_all = "camelCase")] +pub struct SonarrDownloadClientConfigSpec { + /// Reference to the Sonarr instance + pub sonarr_instance_ref: SonarrInstanceRef, + + /// Working folders for download client (container path mapping) + #[serde(default)] + pub download_client_working_folders: Option, + + /// Enable completed download handling + #[serde(default)] + pub enable_completed_download_handling: Option, + + /// Automatically redownload failed releases + #[serde(default)] + pub auto_redownload_failed: Option, + + /// Automatically redownload failed releases from interactive search + #[serde(default)] + pub auto_redownload_failed_from_interactive_search: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SonarrDownloadClientConfigStatus { + /// Current conditions + #[serde(default)] + pub conditions: Vec, + + /// Observed generation + #[serde(default)] + pub observed_generation: i64, +} diff --git a/src/crds/import_list.rs b/src/crds/import_list.rs new file mode 100644 index 0000000..d91a1f7 --- /dev/null +++ b/src/crds/import_list.rs @@ -0,0 +1,204 @@ +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::SonarrInstanceRef; + +/// SonarrImportList represents an import list configuration in Sonarr +/// Import lists automatically add series from external sources (Trakt, Plex, etc.) +#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[kube( + group = "devopsarr.io", + version = "v1alpha1", + kind = "SonarrImportList", + plural = "sonarrimportlists", + shortname = "sil", + namespaced, + status = "SonarrImportListStatus", + printcolumn = r#"{"name":"Name","type":"string","jsonPath":".spec.name"}"#, + printcolumn = r#"{"name":"Type","type":"string","jsonPath":".spec.listType"}"#, + printcolumn = r#"{"name":"ID","type":"integer","jsonPath":".status.id"}"#, + printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type==\"Ready\")].status"}"#, + printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"# +)] +#[serde(rename_all = "camelCase")] +pub struct SonarrImportListSpec { + /// Reference to the SonarrInstance + pub sonarr_instance_ref: SonarrInstanceRef, + + /// Import list name + pub name: String, + + /// Import list type/implementation + pub list_type: ImportListType, + + /// Enable automatic add + #[serde(default = "default_true")] + pub enable_automatic_add: bool, + + /// Search for missing episodes when adding + #[serde(default)] + pub search_for_missing_episodes: bool, + + /// Monitor type for imported series + #[serde(default)] + pub should_monitor: MonitorTypes, + + /// Monitor new items + #[serde(default)] + pub monitor_new_items: NewItemMonitorTypes, + + /// Root folder path for imported series + pub root_folder_path: String, + + /// Quality profile ID to use + pub quality_profile_id: i32, + + /// Series type + #[serde(default)] + pub series_type: SeriesTypes, + + /// Use season folders + #[serde(default = "default_true")] + pub season_folder: bool, + + /// List order + #[serde(default)] + pub list_order: i32, + + /// Tags for imported series + #[serde(default)] + pub tags: Vec, + + /// Import list configuration + #[serde(default)] + pub config: ImportListConfig, +} + +fn default_true() -> bool { + true +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub enum ImportListType { + /// Sonarr Import List + #[default] + SonarrImport, + /// Trakt List + TraktListImport, + /// Trakt User + TraktUserImport, + /// Trakt Popular + TraktPopularImport, + /// Plex Watchlist + PlexImport, + /// IMDb Lists + ImdbListImport, + /// Custom List + CustomImport, + /// Simkl + SimklImport, + /// AniList + AniListImport, + /// MyAnimeList + MyAnimeListImport, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub enum MonitorTypes { + #[default] + All, + Future, + Missing, + Existing, + FirstSeason, + LatestSeason, + Pilot, + MonitorSpecials, + UnmonitorSpecials, + None, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub enum NewItemMonitorTypes { + #[default] + All, + None, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub enum SeriesTypes { + #[default] + Standard, + Daily, + Anime, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ImportListConfig { + /// Base URL (for Sonarr import) + #[serde(default)] + pub base_url: Option, + + /// API key (for Sonarr import) + #[serde(default)] + pub api_key: Option, + + /// Access token (for Trakt/Plex) + #[serde(default)] + pub access_token: Option, + + /// Username (for various services) + #[serde(default)] + pub username: Option, + + /// Auth user (for Trakt) + #[serde(default)] + pub auth_user: Option, + + /// List name/ID + #[serde(default)] + pub listname: Option, + + /// List ID + #[serde(default)] + pub list_id: Option, + + /// Trakt list type + #[serde(default)] + pub trakt_list_type: Option, + + /// Language profile ID (deprecated in v4) + #[serde(default)] + pub language_profile_id: Option, + + /// Profile IDs (for Sonarr import) + #[serde(default)] + pub profile_ids: Vec, + + /// Tag IDs (for Sonarr import) + #[serde(default)] + pub tag_ids: Vec, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SonarrImportListStatus { + /// Current conditions + #[serde(default)] + pub conditions: Vec, + + /// Sonarr Import List ID + #[serde(default)] + pub id: Option, + + /// Observed generation + #[serde(default)] + pub observed_generation: i64, +} diff --git a/src/crds/indexer.rs b/src/crds/indexer.rs new file mode 100644 index 0000000..19450eb --- /dev/null +++ b/src/crds/indexer.rs @@ -0,0 +1,166 @@ +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::SecretKeySelector; +use super::SonarrInstanceRef; + +/// SonarrIndexer represents an indexer configuration in Sonarr +/// Indexers are sources for finding releases (Newznab, Torznab, etc.) +#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[kube( + group = "devopsarr.io", + version = "v1alpha1", + kind = "SonarrIndexer", + plural = "sonarrindexers", + shortname = "sidx", + namespaced, + status = "SonarrIndexerStatus", + printcolumn = r#"{"name":"Name","type":"string","jsonPath":".spec.name"}"#, + printcolumn = r#"{"name":"Type","type":"string","jsonPath":".spec.indexerType"}"#, + printcolumn = r#"{"name":"ID","type":"integer","jsonPath":".status.id"}"#, + printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type==\"Ready\")].status"}"#, + printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"# +)] +#[serde(rename_all = "camelCase")] +pub struct SonarrIndexerSpec { + /// Reference to the SonarrInstance + pub sonarr_instance_ref: SonarrInstanceRef, + + /// Indexer name + pub name: String, + + /// Indexer type (Newznab, Torznab, etc.) + pub indexer_type: IndexerType, + + /// Enable RSS feeds + #[serde(default = "default_true")] + pub enable_rss: bool, + + /// Enable automatic search + #[serde(default = "default_true")] + pub enable_automatic_search: bool, + + /// Enable interactive search + #[serde(default = "default_true")] + pub enable_interactive_search: bool, + + /// Priority for this indexer + #[serde(default = "default_priority")] + pub priority: i32, + + /// Download client ID to use + #[serde(default)] + pub download_client_id: Option, + + /// Tags for this indexer + #[serde(default)] + pub tags: Vec, + + /// Indexer-specific configuration + pub config: IndexerConfig, +} + +fn default_true() -> bool { + true +} + +fn default_priority() -> i32 { + 25 +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum IndexerType { + Newznab, + Torznab, + Fanzub, + BroadcastheNet, + FileList, + HDBits, + IPTorrents, + Nyaa, + TorrentRss, + TorrentLeech, +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct IndexerConfig { + /// Base URL for the indexer + #[serde(default)] + pub base_url: Option, + + /// API path (default: /api) + #[serde(default)] + pub api_path: Option, + + /// API key (can reference a secret) + #[serde(default)] + pub api_key: Option, + + /// API key from secret reference + #[serde(default)] + pub api_key_secret_ref: Option, + + /// Categories to search + #[serde(default)] + pub categories: Vec, + + /// Anime categories + #[serde(default)] + pub anime_categories: Vec, + + /// Search anime in standard format + #[serde(default)] + pub anime_standard_format_search: bool, + + /// Additional parameters + #[serde(default)] + pub additional_parameters: Option, + + /// Minimum seeders (for torrent indexers) + #[serde(default)] + pub minimum_seeders: Option, + + /// Seed ratio (for torrent indexers) + #[serde(default)] + pub seed_ratio: Option, + + /// Seed time (for torrent indexers) + #[serde(default)] + pub seed_time: Option, + + /// Cookie (for some indexers) + #[serde(default)] + pub cookie: Option, + + /// Username (for some indexers) + #[serde(default)] + pub username: Option, + + /// Password secret reference (for some indexers) + #[serde(default)] + pub password_secret_ref: Option, + + /// Passkey (for some indexers) + #[serde(default)] + pub passkey: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SonarrIndexerStatus { + /// Current conditions + #[serde(default)] + pub conditions: Vec, + + /// Sonarr Indexer ID + #[serde(default)] + pub id: Option, + + /// Observed generation + #[serde(default)] + pub observed_generation: i64, +} diff --git a/src/crds/indexer_config.rs b/src/crds/indexer_config.rs new file mode 100644 index 0000000..5f06971 --- /dev/null +++ b/src/crds/indexer_config.rs @@ -0,0 +1,62 @@ +//! SonarrIndexerConfig CRD +//! +//! Configures global indexer settings for a Sonarr instance. +//! Only one resource per Sonarr instance is allowed. + +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::SonarrInstanceRef; + +/// SonarrIndexerConfig configures global indexer settings for a Sonarr instance. +/// Only one SonarrIndexerConfig per Sonarr instance is allowed. +/// Note: This is different from SonarrIndexer which configures individual indexers. +#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema, Default)] +#[kube( + group = "devopsarr.io", + version = "v1alpha1", + kind = "SonarrIndexerConfig", + plural = "sonarrindexerconfigs", + shortname = "sic", + namespaced, + status = "SonarrIndexerConfigStatus", + printcolumn = r#"{"name":"Instance","type":"string","jsonPath":".spec.sonarrInstanceRef.name"}"#, + printcolumn = r#"{"name":"RSS Interval","type":"integer","jsonPath":".spec.rssSyncInterval"}"#, + printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type==\"Ready\")].status"}"#, + printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"# +)] +#[serde(rename_all = "camelCase")] +pub struct SonarrIndexerConfigSpec { + /// Reference to the Sonarr instance + pub sonarr_instance_ref: SonarrInstanceRef, + + /// Minimum age in minutes before downloading (usenet) + #[serde(default)] + pub minimum_age: Option, + + /// Retention in days (0 = unlimited) + #[serde(default)] + pub retention: Option, + + /// Maximum release size in MB (0 = unlimited) + #[serde(default)] + pub maximum_size: Option, + + /// RSS sync interval in minutes (0 = disabled, minimum 10) + #[serde(default)] + pub rss_sync_interval: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SonarrIndexerConfigStatus { + /// Current conditions + #[serde(default)] + pub conditions: Vec, + + /// Observed generation + #[serde(default)] + pub observed_generation: i64, +} diff --git a/src/crds/language_profile.rs b/src/crds/language_profile.rs new file mode 100644 index 0000000..488ceae --- /dev/null +++ b/src/crds/language_profile.rs @@ -0,0 +1,133 @@ +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::SonarrInstanceRef; + +/// SonarrLanguageProfile represents a language profile configuration in Sonarr +/// Language profiles define preferred languages for downloading series +/// Note: Deprecated in Sonarr v4, replaced by per-series language selection +#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[kube( + group = "devopsarr.io", + version = "v1alpha1", + kind = "SonarrLanguageProfile", + plural = "sonarrlanguageprofiles", + shortname = "slp", + namespaced, + status = "SonarrLanguageProfileStatus", + printcolumn = r#"{"name":"Name","type":"string","jsonPath":".spec.name"}"#, + printcolumn = r#"{"name":"Cutoff","type":"string","jsonPath":".spec.cutoffLanguage"}"#, + printcolumn = r#"{"name":"ID","type":"integer","jsonPath":".status.id"}"#, + printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type==\"Ready\")].status"}"#, + printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"# +)] +#[serde(rename_all = "camelCase")] +pub struct SonarrLanguageProfileSpec { + /// Reference to the SonarrInstance + pub sonarr_instance_ref: SonarrInstanceRef, + + /// Language profile name + pub name: String, + + /// Allow upgrades to better quality languages + #[serde(default)] + pub upgrade_allowed: bool, + + /// Cutoff language - stop upgrading when this language is reached + pub cutoff_language: LanguageType, + + /// Ordered list of languages (first = highest priority) + pub languages: Vec, +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct LanguageItem { + /// Language + pub language: LanguageType, + + /// Whether this language is allowed + #[serde(default = "default_true")] + pub allowed: bool, +} + +fn default_true() -> bool { + true +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "PascalCase")] +pub enum LanguageType { + Unknown, + #[default] + English, + French, + Spanish, + German, + Italian, + Danish, + Dutch, + Japanese, + Icelandic, + Chinese, + Russian, + Polish, + Vietnamese, + Swedish, + Norwegian, + Finnish, + Turkish, + Portuguese, + Flemish, + Greek, + Korean, + Hungarian, + Hebrew, + Lithuanian, + Czech, + Hindi, + Romanian, + Thai, + Bulgarian, + #[serde(rename = "PortugueseBrazil")] + PortugueseBrazil, + Arabic, + Ukrainian, + Persian, + Bengali, + Slovak, + Latvian, + #[serde(rename = "SpanishLatino")] + SpanishLatino, + Catalan, + Croatian, + Serbian, + Bosnian, + Estonian, + Tamil, + Indonesian, + Telugu, + Macedonian, + Slovenian, + Malay, + Original, + Any, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SonarrLanguageProfileStatus { + /// Current conditions + #[serde(default)] + pub conditions: Vec, + + /// Sonarr Language Profile ID + #[serde(default)] + pub id: Option, + + /// Observed generation + #[serde(default)] + pub observed_generation: i64, +} diff --git a/src/crds/media_management_config.rs b/src/crds/media_management_config.rs new file mode 100644 index 0000000..d9f1ff4 --- /dev/null +++ b/src/crds/media_management_config.rs @@ -0,0 +1,160 @@ +//! SonarrMediaManagementConfig CRD +//! +//! Configures media management settings for a Sonarr instance. +//! Only one resource per Sonarr instance is allowed. + +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::SonarrInstanceRef; + +/// SonarrMediaManagementConfig configures media management settings for a Sonarr instance. +/// Only one SonarrMediaManagementConfig per Sonarr instance is allowed. +#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema, Default)] +#[kube( + group = "devopsarr.io", + version = "v1alpha1", + kind = "SonarrMediaManagementConfig", + plural = "sonarrmediamanagementconfigs", + shortname = "smmc", + namespaced, + status = "SonarrMediaManagementConfigStatus", + printcolumn = r#"{"name":"Instance","type":"string","jsonPath":".spec.sonarrInstanceRef.name"}"#, + printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type==\"Ready\")].status"}"#, + printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"# +)] +#[serde(rename_all = "camelCase")] +pub struct SonarrMediaManagementConfigSpec { + /// Reference to the Sonarr instance + pub sonarr_instance_ref: SonarrInstanceRef, + + /// Auto unmonitor previously downloaded episodes when marked as deleted + #[serde(default)] + pub auto_unmonitor_previously_downloaded_episodes: Option, + + /// Recycle bin path (empty to disable) + #[serde(default)] + pub recycle_bin: Option, + + /// Days to keep files in recycle bin before cleaning (0 to disable) + #[serde(default)] + pub recycle_bin_cleanup_days: Option, + + /// Download propers and repacks: DoNotPrefer, PreferAndUpgrade, DoNotUpgrade + #[serde(default, skip_serializing_if = "Option::is_none")] + pub download_propers_and_repacks: Option, + + /// Create empty series folders during disk scan + #[serde(default)] + pub create_empty_series_folders: Option, + + /// Delete empty series and season folders during disk scan + #[serde(default)] + pub delete_empty_folders: Option, + + /// File date to use: None, LocalAirDate, UtcAirDate + #[serde(default, skip_serializing_if = "Option::is_none")] + pub file_date: Option, + + /// Rescan series folder after refresh: Always, AfterManual, Never + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rescan_after_refresh: Option, + + /// Set permissions on Linux/macOS + #[serde(default)] + pub set_permissions_linux: Option, + + /// chmod folder permissions (e.g., "755") + #[serde(default)] + pub chmod_folder: Option, + + /// chown group + #[serde(default)] + pub chown_group: Option, + + /// Episode title required: Always, BulkSeasonReleases, Never + #[serde(default, skip_serializing_if = "Option::is_none")] + pub episode_title_required: Option, + + /// Skip free space check when importing + #[serde(default)] + pub skip_free_space_check_when_importing: Option, + + /// Minimum free space when importing (MB) + #[serde(default)] + pub minimum_free_space_when_importing: Option, + + /// Use hardlinks instead of copy when possible + #[serde(default)] + pub copy_using_hardlinks: Option, + + /// Use script for importing + #[serde(default)] + pub use_script_import: Option, + + /// Script import path + #[serde(default)] + pub script_import_path: Option, + + /// Import extra files (subtitles, etc.) + #[serde(default)] + pub import_extra_files: Option, + + /// Extra file extensions to import (e.g., "srt,sub") + #[serde(default)] + pub extra_file_extensions: Option, + + /// Enable media info scanning + #[serde(default)] + pub enable_media_info: Option, +} + +/// How to handle propers and repacks +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema, PartialEq)] +pub enum ProperDownloadType { + #[default] + DoNotPrefer, + PreferAndUpgrade, + DoNotUpgrade, +} + +/// File date type to use +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema, PartialEq)] +pub enum FileDateType { + #[default] + None, + LocalAirDate, + UtcAirDate, +} + +/// When to rescan after refresh +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema, PartialEq)] +pub enum RescanAfterRefreshType { + #[default] + Always, + AfterManual, + Never, +} + +/// When episode title is required +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema, PartialEq)] +pub enum EpisodeTitleRequiredType { + #[default] + Always, + BulkSeasonReleases, + Never, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SonarrMediaManagementConfigStatus { + /// Current conditions + #[serde(default)] + pub conditions: Vec, + + /// Observed generation + #[serde(default)] + pub observed_generation: i64, +} diff --git a/src/crds/metadata.rs b/src/crds/metadata.rs new file mode 100644 index 0000000..6dfad54 --- /dev/null +++ b/src/crds/metadata.rs @@ -0,0 +1,108 @@ +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::SonarrInstanceRef; + +/// SonarrMetadata represents a metadata consumer configuration in Sonarr +/// Metadata consumers write metadata files for media managers (Kodi, Plex, etc.) +#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[kube( + group = "devopsarr.io", + version = "v1alpha1", + kind = "SonarrMetadata", + plural = "sonarrmetadatas", + shortname = "smeta", + namespaced, + status = "SonarrMetadataStatus", + printcolumn = r#"{"name":"Name","type":"string","jsonPath":".spec.name"}"#, + printcolumn = r#"{"name":"Type","type":"string","jsonPath":".spec.metadataType"}"#, + printcolumn = r#"{"name":"Enabled","type":"boolean","jsonPath":".spec.enable"}"#, + printcolumn = r#"{"name":"ID","type":"integer","jsonPath":".status.id"}"#, + printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type==\"Ready\")].status"}"#, + printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"# +)] +#[serde(rename_all = "camelCase")] +pub struct SonarrMetadataSpec { + /// Reference to the SonarrInstance + pub sonarr_instance_ref: SonarrInstanceRef, + + /// Metadata consumer name + pub name: String, + + /// Metadata type/implementation + pub metadata_type: MetadataType, + + /// Enable this metadata consumer + #[serde(default = "default_true")] + pub enable: bool, + + /// Tags for this metadata consumer + #[serde(default)] + pub tags: Vec, + + /// Metadata-specific configuration + #[serde(default)] + pub config: MetadataConfig, +} + +fn default_true() -> bool { + true +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub enum MetadataType { + /// Kodi (XBMC) / Emby metadata + #[default] + XbmcMetadata, + /// Roksbox metadata + RoksboxMetadata, + /// WDTV metadata + WdtvMetadata, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct MetadataConfig { + /// Write series metadata (series.nfo) + #[serde(default = "default_true")] + pub series_metadata: bool, + + /// Write series metadata URL (deprecated) + #[serde(default)] + pub series_metadata_url: bool, + + /// Write episode metadata (episode.nfo) + #[serde(default = "default_true")] + pub episode_metadata: bool, + + /// Write series images (poster, banner, fanart) + #[serde(default = "default_true")] + pub series_images: bool, + + /// Write season images + #[serde(default = "default_true")] + pub season_images: bool, + + /// Write episode images (thumbnails) + #[serde(default)] + pub episode_images: bool, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SonarrMetadataStatus { + /// Current conditions + #[serde(default)] + pub conditions: Vec, + + /// Sonarr Metadata ID + #[serde(default)] + pub id: Option, + + /// Observed generation + #[serde(default)] + pub observed_generation: i64, +} diff --git a/src/crds/mod.rs b/src/crds/mod.rs new file mode 100644 index 0000000..3661abc --- /dev/null +++ b/src/crds/mod.rs @@ -0,0 +1,114 @@ +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +pub mod auto_tag; +pub mod custom_format; +pub mod delay_profile; +pub mod download_client; +pub mod download_client_config; +pub mod import_list; +pub mod indexer; +pub mod indexer_config; +pub mod language_profile; +pub mod media_management_config; +pub mod metadata; +pub mod naming_config; +pub mod notification; +pub mod quality_definition; +pub mod quality_profile; +pub mod root_folder; +pub mod series; +pub mod sonarr; +pub mod tag; + +pub use auto_tag::{SonarrAutoTag, SonarrAutoTagSpec, SonarrAutoTagStatus}; +pub use custom_format::{SonarrCustomFormat, SonarrCustomFormatSpec, SonarrCustomFormatStatus}; +pub use delay_profile::{SonarrDelayProfile, SonarrDelayProfileSpec, SonarrDelayProfileStatus}; +pub use download_client::{ + SonarrDownloadClient, SonarrDownloadClientSpec, SonarrDownloadClientStatus, +}; +pub use import_list::{SonarrImportList, SonarrImportListSpec, SonarrImportListStatus}; +pub use indexer::{SonarrIndexer, SonarrIndexerSpec, SonarrIndexerStatus}; +pub use language_profile::{ + SonarrLanguageProfile, SonarrLanguageProfileSpec, SonarrLanguageProfileStatus, +}; +pub use metadata::{SonarrMetadata, SonarrMetadataSpec, SonarrMetadataStatus}; +pub use notification::{SonarrNotification, SonarrNotificationSpec, SonarrNotificationStatus}; +pub use quality_definition::{ + SonarrQualityDefinition, SonarrQualityDefinitionSpec, SonarrQualityDefinitionStatus, +}; +pub use quality_profile::{ + SonarrQualityProfile, SonarrQualityProfileSpec, SonarrQualityProfileStatus, +}; +pub use root_folder::{SonarrRootFolder, SonarrRootFolderSpec, SonarrRootFolderStatus}; +pub use series::{SonarrSeries, SonarrSeriesSpec, SonarrSeriesStatus}; +pub use sonarr::{ServiceConfig, Sonarr, SonarrSpec, SonarrStatus, StorageConfig}; +pub use tag::{SonarrTag, SonarrTagSpec, SonarrTagStatus}; + +// Config CRDs (singleton per Sonarr instance) +pub use download_client_config::{ + SonarrDownloadClientConfig, SonarrDownloadClientConfigSpec, SonarrDownloadClientConfigStatus, +}; +pub use indexer_config::{SonarrIndexerConfig, SonarrIndexerConfigSpec, SonarrIndexerConfigStatus}; +pub use media_management_config::{ + SonarrMediaManagementConfig, SonarrMediaManagementConfigSpec, SonarrMediaManagementConfigStatus, +}; +pub use naming_config::{SonarrNamingConfig, SonarrNamingConfigSpec, SonarrNamingConfigStatus}; + +/// Common SecretKeySelector used across CRDs +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SecretKeySelector { + /// Name of the secret + pub name: String, + + /// Key in the secret + pub key: String, +} + +/// Reference to a Sonarr instance used by sub-resources +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SonarrInstanceRef { + /// Name of the SonarrInstance resource + #[serde(default)] + pub name: String, + + /// Namespace of the SonarrInstance (optional, defaults to same namespace) + #[serde(default)] + pub namespace: Option, +} + +/// Base status fields shared by all Sonarr sub-resources (tags, notifications, etc.) +/// This provides a consistent status structure with conditions, id, and observed generation. +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SonarrSubResourceStatus { + /// Current conditions + #[serde(default)] + pub conditions: Vec, + + /// Sonarr resource ID + #[serde(default)] + pub id: Option, + + /// Observed generation + #[serde(default)] + pub observed_generation: i64, +} + +/// Common constants for the operator +pub const FINALIZER: &str = "sonarr.io/finalizer"; +pub const SONARR_GROUP: &str = "devopsarr.io"; +pub const SONARR_VERSION: &str = "v1alpha1"; + +/// Common labels +pub const LABEL_APP: &str = "app.kubernetes.io/name"; +pub const LABEL_INSTANCE: &str = "app.kubernetes.io/instance"; +pub const LABEL_MANAGED_BY: &str = "app.kubernetes.io/managed-by"; +pub const LABEL_COMPONENT: &str = "app.kubernetes.io/component"; + +/// Annotations +pub const ANNOTATION_SONARR_REF: &str = "sonarr.io/instance"; +pub const ANNOTATION_RESOURCE_ID: &str = "sonarr.io/resource-id"; diff --git a/src/crds/naming_config.rs b/src/crds/naming_config.rs new file mode 100644 index 0000000..583212d --- /dev/null +++ b/src/crds/naming_config.rs @@ -0,0 +1,95 @@ +//! SonarrNamingConfig CRD +//! +//! Configures episode naming settings for a Sonarr instance. +//! Only one resource per Sonarr instance is allowed. + +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::SonarrInstanceRef; + +/// SonarrNamingConfig configures episode naming settings for a Sonarr instance. +/// Only one SonarrNamingConfig per Sonarr instance is allowed. +#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema, Default)] +#[kube( + group = "devopsarr.io", + version = "v1alpha1", + kind = "SonarrNamingConfig", + plural = "sonarrnamingconfigs", + shortname = "snc", + namespaced, + status = "SonarrNamingConfigStatus", + printcolumn = r#"{"name":"Instance","type":"string","jsonPath":".spec.sonarrInstanceRef.name"}"#, + printcolumn = r#"{"name":"Rename","type":"boolean","jsonPath":".spec.renameEpisodes"}"#, + printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type==\"Ready\")].status"}"#, + printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"# +)] +#[serde(rename_all = "camelCase")] +pub struct SonarrNamingConfigSpec { + /// Reference to the Sonarr instance + pub sonarr_instance_ref: SonarrInstanceRef, + + /// Enable episode renaming + #[serde(default)] + pub rename_episodes: Option, + + /// Replace illegal characters in filenames + #[serde(default)] + pub replace_illegal_characters: Option, + + /// Colon replacement format (0=Delete, 1=Dash, 2=SpaceDash, 3=SpaceDashSpace, 4=Smart) + #[serde(default)] + pub colon_replacement_format: Option, + + /// Custom colon replacement format string + #[serde(default)] + pub custom_colon_replacement_format: Option, + + /// Multi-episode style (0=Extend, 1=Duplicate, 2=Repeat, 3=Scene, 4=Range, 5=PrefixedRange) + #[serde(default)] + pub multi_episode_style: Option, + + /// Standard episode format + /// Example: "{Series Title} - S{season:00}E{episode:00} - {Episode Title} {Quality Full}" + #[serde(default)] + pub standard_episode_format: Option, + + /// Daily episode format + /// Example: "{Series Title} - {Air-Date} - {Episode Title} {Quality Full}" + #[serde(default)] + pub daily_episode_format: Option, + + /// Anime episode format + /// Example: "{Series Title} - S{season:00}E{episode:00} - {Episode Title} {Quality Full}" + #[serde(default)] + pub anime_episode_format: Option, + + /// Series folder format + /// Example: "{Series Title}" + #[serde(default)] + pub series_folder_format: Option, + + /// Season folder format + /// Example: "Season {season}" + #[serde(default)] + pub season_folder_format: Option, + + /// Specials folder format + /// Example: "Specials" + #[serde(default)] + pub specials_folder_format: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SonarrNamingConfigStatus { + /// Current conditions + #[serde(default)] + pub conditions: Vec, + + /// Observed generation + #[serde(default)] + pub observed_generation: i64, +} diff --git a/src/crds/notification.rs b/src/crds/notification.rs new file mode 100644 index 0000000..0ae207b --- /dev/null +++ b/src/crds/notification.rs @@ -0,0 +1,321 @@ +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::SonarrInstanceRef; +use super::SecretKeySelector; + +/// SonarrNotification represents a notification/connect configuration in Sonarr +/// Notifications are used to alert on events (Discord, Telegram, Webhook, etc.) +#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[kube( + group = "devopsarr.io", + version = "v1alpha1", + kind = "SonarrNotification", + plural = "sonarrnotifications", + shortname = "snot", + namespaced, + status = "SonarrNotificationStatus", + printcolumn = r#"{"name":"Name","type":"string","jsonPath":".spec.name"}"#, + printcolumn = r#"{"name":"Type","type":"string","jsonPath":".spec.notificationType"}"#, + printcolumn = r#"{"name":"ID","type":"integer","jsonPath":".status.id"}"#, + printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type==\"Ready\")].status"}"#, + printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"# +)] +#[serde(rename_all = "camelCase")] +pub struct SonarrNotificationSpec { + /// Reference to the SonarrInstance + pub sonarr_instance_ref: SonarrInstanceRef, + + /// Notification name + pub name: String, + + /// Notification type + pub notification_type: NotificationType, + + /// Tags for this notification + #[serde(default)] + pub tags: Vec, + + /// Event triggers + #[serde(default)] + pub triggers: NotificationTriggers, + + /// Notification configuration + pub config: NotificationConfig, +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "PascalCase")] +pub enum NotificationType { + Apprise, + CustomScript, + Discord, + Email, + Emby, + Gotify, + Join, + Kodi, + Mailgun, + Ntfy, + Plex, + Prowl, + Pushbullet, + Pushover, + SendGrid, + Signal, + Simplepush, + Slack, + SynologyIndexer, + Telegram, + Trakt, + Twitter, + Webhook, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct NotificationTriggers { + /// On grab (episode is grabbed) + #[serde(default)] + pub on_grab: bool, + + /// On download (episode is downloaded) + #[serde(default)] + pub on_download: bool, + + /// On upgrade (episode is upgraded) + #[serde(default)] + pub on_upgrade: bool, + + /// On rename + #[serde(default)] + pub on_rename: bool, + + /// On series add + #[serde(default)] + pub on_series_add: bool, + + /// On series delete + #[serde(default)] + pub on_series_delete: bool, + + /// On episode file delete + #[serde(default)] + pub on_episode_file_delete: bool, + + /// On episode file delete for upgrade + #[serde(default)] + pub on_episode_file_delete_for_upgrade: bool, + + /// On health issue + #[serde(default)] + pub on_health_issue: bool, + + /// On health restored + #[serde(default)] + pub on_health_restored: bool, + + /// On application update + #[serde(default)] + pub on_application_update: bool, + + /// On manual interaction required + #[serde(default)] + pub on_manual_interaction_required: bool, + + /// On import complete + #[serde(default)] + pub on_import_complete: bool, + + /// Include health warnings + #[serde(default)] + pub include_health_warnings: bool, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct NotificationConfig { + // Webhook configuration + /// Webhook URL + #[serde(default)] + pub url: Option, + + /// HTTP Method (1 = POST, 2 = PUT) + #[serde(default)] + pub method: Option, + + /// Username for basic auth + #[serde(default)] + pub username: Option, + + /// Password secret reference + #[serde(default)] + pub password_secret_ref: Option, + + // Discord configuration + /// Discord webhook URL + #[serde(default)] + pub webhook_url: Option, + + /// Discord avatar + #[serde(default)] + pub avatar: Option, + + /// Discord username + #[serde(default)] + pub discord_username: Option, + + // Telegram configuration + /// Telegram bot token secret reference + #[serde(default)] + pub bot_token_secret_ref: Option, + + /// Telegram chat ID + #[serde(default)] + pub chat_id: Option, + + /// Send silently + #[serde(default)] + pub send_silently: bool, + + // Email configuration + /// SMTP server + #[serde(default)] + pub server: Option, + + /// SMTP port + #[serde(default)] + pub port: Option, + + /// Use SSL + #[serde(default)] + pub use_ssl: bool, + + /// Require encryption + #[serde(default)] + pub require_encryption: bool, + + /// From address + #[serde(default)] + pub from: Option, + + /// To addresses + #[serde(default)] + pub to: Vec, + + /// CC addresses + #[serde(default)] + pub cc: Vec, + + /// BCC addresses + #[serde(default)] + pub bcc: Vec, + + // Slack configuration + /// Slack webhook URL + #[serde(default)] + pub slack_webhook_url: Option, + + /// Slack channel + #[serde(default)] + pub channel: Option, + + /// Slack icon + #[serde(default)] + pub icon: Option, + + // Plex/Emby configuration + /// Server host + #[serde(default)] + pub host: Option, + + /// Auth token secret reference + #[serde(default)] + pub auth_token_secret_ref: Option, + + /// Update library + #[serde(default)] + pub update_library: bool, + + /// Notify on specific library sections + #[serde(default)] + pub map_to: Option, + + // Gotify configuration + /// Gotify app token secret reference + #[serde(default)] + pub app_token_secret_ref: Option, + + /// Priority level + #[serde(default)] + pub priority: Option, + + // Pushover configuration + /// User key secret reference + #[serde(default)] + pub user_key_secret_ref: Option, + + /// API key secret reference + #[serde(default)] + pub api_key_secret_ref: Option, + + /// Device list + #[serde(default)] + pub devices: Vec, + + /// Sound + #[serde(default)] + pub sound: Option, + + /// Retry interval (seconds) + #[serde(default)] + pub retry: Option, + + /// Expire after (seconds) + #[serde(default)] + pub expire: Option, + + // Custom Script configuration + /// Path to script + #[serde(default)] + pub path: Option, + + /// Script arguments + #[serde(default)] + pub arguments: Option, + + // Ntfy configuration + /// Ntfy server URL + #[serde(default)] + pub server_url: Option, + + /// Ntfy topic + #[serde(default)] + pub topic: Option, + + /// Click URL + #[serde(default)] + pub click_url: Option, + + /// Ntfy tags + #[serde(default)] + pub ntfy_tags: Vec, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SonarrNotificationStatus { + /// Current conditions + #[serde(default)] + pub conditions: Vec, + + /// Sonarr Notification ID + #[serde(default)] + pub id: Option, + + /// Observed generation + #[serde(default)] + pub observed_generation: i64, +} diff --git a/src/crds/quality_definition.rs b/src/crds/quality_definition.rs new file mode 100644 index 0000000..b4a8451 --- /dev/null +++ b/src/crds/quality_definition.rs @@ -0,0 +1,141 @@ +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::SonarrInstanceRef; + +/// SonarrQualityDefinition represents a quality definition configuration in Sonarr +/// Quality definitions control the size limits for each quality level +#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[kube( + group = "devopsarr.io", + version = "v1alpha1", + kind = "SonarrQualityDefinition", + plural = "sonarrqualitydefinitions", + shortname = "sqd", + namespaced, + status = "SonarrQualityDefinitionStatus", + printcolumn = r#"{"name":"Quality","type":"string","jsonPath":".spec.qualityName"}"#, + printcolumn = r#"{"name":"Title","type":"string","jsonPath":".spec.title"}"#, + printcolumn = r#"{"name":"MinSize","type":"number","jsonPath":".spec.minSize"}"#, + printcolumn = r#"{"name":"MaxSize","type":"number","jsonPath":".spec.maxSize"}"#, + printcolumn = r#"{"name":"ID","type":"integer","jsonPath":".status.id"}"#, + printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type==\"Ready\")].status"}"#, + printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"# +)] +#[serde(rename_all = "camelCase")] +pub struct SonarrQualityDefinitionSpec { + /// Reference to the SonarrInstance + pub sonarr_instance_ref: SonarrInstanceRef, + + /// Quality name (must match existing quality in Sonarr) + pub quality_name: QualityName, + + /// Title/display name for this quality + #[serde(default)] + pub title: Option, + + /// Minimum size in MB per minute of runtime + #[serde(default)] + pub min_size: Option, + + /// Maximum size in MB per minute of runtime (None = unlimited) + #[serde(default)] + pub max_size: Option, + + /// Preferred size in MB per minute of runtime + #[serde(default)] + pub preferred_size: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum QualityName { + Unknown, + #[default] + #[serde(rename = "SDTV")] + Sdtv, + #[serde(rename = "DVD")] + Dvd, + #[serde(rename = "WEBDL-480p")] + Webdl480p, + #[serde(rename = "WEBRip-480p")] + Webrip480p, + #[serde(rename = "Bluray-480p")] + Bluray480p, + #[serde(rename = "HDTV-720p")] + Hdtv720p, + #[serde(rename = "HDTV-1080p")] + Hdtv1080p, + #[serde(rename = "Raw-HD")] + RawHd, + #[serde(rename = "WEBDL-720p")] + Webdl720p, + #[serde(rename = "WEBRip-720p")] + Webrip720p, + #[serde(rename = "Bluray-720p")] + Bluray720p, + #[serde(rename = "WEBDL-1080p")] + Webdl1080p, + #[serde(rename = "WEBRip-1080p")] + Webrip1080p, + #[serde(rename = "Bluray-1080p")] + Bluray1080p, + #[serde(rename = "Bluray-1080p Remux")] + Bluray1080pRemux, + #[serde(rename = "HDTV-2160p")] + Hdtv2160p, + #[serde(rename = "WEBDL-2160p")] + Webdl2160p, + #[serde(rename = "WEBRip-2160p")] + Webrip2160p, + #[serde(rename = "Bluray-2160p")] + Bluray2160p, + #[serde(rename = "Bluray-2160p Remux")] + Bluray2160pRemux, +} + +impl QualityName { + pub fn to_quality_id(&self) -> i32 { + match self { + QualityName::Unknown => 0, + QualityName::Sdtv => 1, + QualityName::Dvd => 2, + QualityName::Webdl480p => 8, + QualityName::Webrip480p => 12, + QualityName::Bluray480p => 20, + QualityName::Hdtv720p => 4, + QualityName::Hdtv1080p => 9, + QualityName::RawHd => 10, + QualityName::Webdl720p => 5, + QualityName::Webrip720p => 14, + QualityName::Bluray720p => 6, + QualityName::Webdl1080p => 3, + QualityName::Webrip1080p => 15, + QualityName::Bluray1080p => 7, + QualityName::Bluray1080pRemux => 30, + QualityName::Hdtv2160p => 16, + QualityName::Webdl2160p => 18, + QualityName::Webrip2160p => 17, + QualityName::Bluray2160p => 19, + QualityName::Bluray2160pRemux => 31, + } + } +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SonarrQualityDefinitionStatus { + /// Current conditions + #[serde(default)] + pub conditions: Vec, + + /// Sonarr Quality Definition ID + #[serde(default)] + pub id: Option, + + /// Observed generation + #[serde(default)] + pub observed_generation: i64, +} diff --git a/src/crds/quality_profile.rs b/src/crds/quality_profile.rs new file mode 100644 index 0000000..571b5ff --- /dev/null +++ b/src/crds/quality_profile.rs @@ -0,0 +1,126 @@ +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::SonarrInstanceRef; + +/// SonarrQualityProfile represents a quality profile in Sonarr +/// Quality profiles define which qualities are acceptable and their priority +#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[kube( + group = "devopsarr.io", + version = "v1alpha1", + kind = "SonarrQualityProfile", + plural = "sonarrqualityprofiles", + shortname = "sqp", + namespaced, + status = "SonarrQualityProfileStatus", + printcolumn = r#"{"name":"Name","type":"string","jsonPath":".spec.name"}"#, + printcolumn = r#"{"name":"Cutoff","type":"integer","jsonPath":".spec.cutoff"}"#, + printcolumn = r#"{"name":"ID","type":"integer","jsonPath":".status.id"}"#, + printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type==\"Ready\")].status"}"#, + printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"# +)] +#[serde(rename_all = "camelCase")] +pub struct SonarrQualityProfileSpec { + /// Reference to the SonarrInstance + pub sonarr_instance_ref: SonarrInstanceRef, + + /// Quality profile name + pub name: String, + + /// Whether upgrades are allowed + #[serde(default)] + pub upgrade_allowed: bool, + + /// Quality ID to use as cutoff + #[serde(default)] + pub cutoff: i32, + + /// Cutoff format score + #[serde(default)] + pub cutoff_format_score: Option, + + /// Minimum format score + #[serde(default)] + pub min_format_score: Option, + + /// Minimum upgrade format score + #[serde(default)] + pub min_upgrade_format_score: Option, + + /// Ordered list of quality groups + pub quality_groups: Vec, + + /// Format items (custom formats with scores) + #[serde(default)] + pub format_items: Vec, +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct QualityGroup { + /// Quality group ID + #[serde(default)] + pub id: Option, + + /// Quality group name + #[serde(default)] + pub name: Option, + + /// Ordered list of qualities in this group + pub qualities: Vec, +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct Quality { + /// Quality ID + #[serde(default)] + pub id: Option, + + /// Quality name + #[serde(default)] + pub name: Option, + + /// Source type + #[serde(default)] + pub source: Option, + + /// Resolution + #[serde(default)] + pub resolution: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct FormatItem { + /// Custom format ID + #[serde(default)] + pub format: Option, + + /// Format name + #[serde(default)] + pub name: Option, + + /// Score for this format + #[serde(default)] + pub score: i32, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SonarrQualityProfileStatus { + /// Current conditions + #[serde(default)] + pub conditions: Vec, + + /// Sonarr Quality Profile ID + #[serde(default)] + pub id: Option, + + /// Observed generation + #[serde(default)] + pub observed_generation: i64, +} diff --git a/src/crds/root_folder.rs b/src/crds/root_folder.rs new file mode 100644 index 0000000..628a949 --- /dev/null +++ b/src/crds/root_folder.rs @@ -0,0 +1,55 @@ +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::SonarrInstanceRef; + +/// SonarrRootFolder represents a root folder in Sonarr +/// Root folders are the base directories where series are stored +#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[kube( + group = "devopsarr.io", + version = "v1alpha1", + kind = "SonarrRootFolder", + plural = "sonarrrootfolders", + shortname = "srf", + namespaced, + status = "SonarrRootFolderStatus", + printcolumn = r#"{"name":"Path","type":"string","jsonPath":".spec.path"}"#, + printcolumn = r#"{"name":"ID","type":"integer","jsonPath":".status.id"}"#, + printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type==\"Ready\")].status"}"#, + printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"# +)] +#[serde(rename_all = "camelCase")] +pub struct SonarrRootFolderSpec { + /// Reference to the SonarrInstance + pub sonarr_instance_ref: SonarrInstanceRef, + + /// Root folder absolute path + pub path: String, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SonarrRootFolderStatus { + /// Current conditions + #[serde(default)] + pub conditions: Vec, + + /// Sonarr Root Folder ID + #[serde(default)] + pub id: Option, + + /// Whether the folder is accessible + #[serde(default)] + pub accessible: Option, + + /// Free space in the folder + #[serde(default)] + pub free_space: Option, + + /// Observed generation + #[serde(default)] + pub observed_generation: i64, +} diff --git a/src/crds/series.rs b/src/crds/series.rs new file mode 100644 index 0000000..f423b83 --- /dev/null +++ b/src/crds/series.rs @@ -0,0 +1,200 @@ +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::SonarrInstanceRef; + +/// SonarrSeries represents a TV series managed in Sonarr +/// This allows declarative management of series in your library +#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[kube( + group = "devopsarr.io", + version = "v1alpha1", + kind = "SonarrSeries", + plural = "sonarrseries", + shortname = "ss", + namespaced, + status = "SonarrSeriesStatus", + printcolumn = r#"{"name":"Title","type":"string","jsonPath":".spec.title"}"#, + printcolumn = r#"{"name":"TVDB ID","type":"integer","jsonPath":".spec.tvdbId"}"#, + printcolumn = r#"{"name":"Monitored","type":"boolean","jsonPath":".spec.monitored"}"#, + printcolumn = r#"{"name":"ID","type":"integer","jsonPath":".status.id"}"#, + printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type==\"Ready\")].status"}"#, + printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"# +)] +#[serde(rename_all = "camelCase")] +pub struct SonarrSeriesSpec { + /// Reference to the SonarrInstance + pub sonarr_instance_ref: SonarrInstanceRef, + + /// Series title + pub title: String, + + /// TVDB ID for the series + pub tvdb_id: i32, + + /// Title slug (kebab-case version of title) + pub title_slug: String, + + /// Quality profile ID or name reference + pub quality_profile: QualityProfileRef, + + /// Root folder path for the series + pub root_folder_path: String, + + /// Whether the series is monitored + #[serde(default = "default_true")] + pub monitored: bool, + + /// Use season folders + #[serde(default = "default_true")] + pub season_folder: bool, + + /// Use scene numbering + #[serde(default)] + pub use_scene_numbering: bool, + + /// Series type + #[serde(default = "default_series_type")] + pub series_type: SeriesType, + + /// Tags for this series + #[serde(default)] + pub tags: Vec, + + /// Specific path override (optional) + #[serde(default)] + pub path: Option, + + /// Monitor type for adding series + #[serde(default = "default_monitor_type")] + pub add_options: AddSeriesOptions, +} + +fn default_true() -> bool { + true +} + +fn default_series_type() -> SeriesType { + SeriesType::Standard +} + +fn default_monitor_type() -> AddSeriesOptions { + AddSeriesOptions { + monitor: MonitorType::All, + search_for_missing_episodes: true, + search_for_cutoff_unmet_episodes: false, + } +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct QualityProfileRef { + /// Quality profile ID + #[serde(default)] + pub id: Option, + + /// Quality profile name (will be resolved to ID) + #[serde(default)] + pub name: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum SeriesType { + Standard, + Daily, + Anime, +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct AddSeriesOptions { + /// Monitor type + #[serde(default = "default_monitor_all")] + pub monitor: MonitorType, + + /// Search for missing episodes when adding + #[serde(default = "default_true_fn")] + pub search_for_missing_episodes: bool, + + /// Search for cutoff unmet episodes + #[serde(default)] + pub search_for_cutoff_unmet_episodes: bool, +} + +fn default_monitor_all() -> MonitorType { + MonitorType::All +} + +fn default_true_fn() -> bool { + true +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum MonitorType { + /// Monitor all episodes + All, + /// Monitor future episodes only + Future, + /// Monitor missing episodes only + Missing, + /// Monitor existing episodes only + Existing, + /// Monitor recent episodes only + Recent, + /// Monitor pilot episode only + Pilot, + /// Monitor first season only + FirstSeason, + /// Monitor last season only + LastSeason, + /// Monitor no episodes + None, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SonarrSeriesStatus { + /// Current conditions + #[serde(default)] + pub conditions: Vec, + + /// Sonarr Series ID + #[serde(default)] + pub id: Option, + + /// Observed generation + #[serde(default)] + pub observed_generation: i64, + + /// Total episode count + #[serde(default)] + pub episode_count: Option, + + /// Episode file count + #[serde(default)] + pub episode_file_count: Option, + + /// Percentage complete + #[serde(default)] + pub percent_complete: Option, + + /// Next airing date + #[serde(default)] + pub next_airing: Option, + + /// Previous airing date + #[serde(default)] + pub previous_airing: Option, + + /// Network + #[serde(default)] + pub network: Option, + + /// Status (continuing, ended, etc.) + #[serde(default)] + pub series_status: Option, +} diff --git a/src/crds/sonarr.rs b/src/crds/sonarr.rs new file mode 100644 index 0000000..2e2cda1 --- /dev/null +++ b/src/crds/sonarr.rs @@ -0,0 +1,636 @@ +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::SecretKeySelector; + +/// Sonarr is the main CRD that deploys and manages a Sonarr instance +/// +/// This CRD creates: +/// - A Deployment with the Sonarr container +/// - An init container for database migrations +/// - A Service to expose Sonarr +/// - A PersistentVolumeClaim for configuration storage +/// - Optional Ingress for external access +#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[kube( + group = "devopsarr.io", + version = "v1alpha1", + kind = "Sonarr", + plural = "sonarrs", + shortname = "snr", + namespaced, + status = "SonarrStatus", + printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type==\"Ready\")].status"}"#, + printcolumn = r#"{"name":"URL","type":"string","jsonPath":".status.url"}"#, + printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"# +)] +#[serde(rename_all = "camelCase")] +pub struct SonarrSpec { + /// Sonarr image to use (default: lscr.io/linuxserver/sonarr:latest) + #[serde(default = "default_image")] + pub image: String, + + /// Image pull policy (default: IfNotPresent) + #[serde(default = "default_image_pull_policy")] + pub image_pull_policy: String, + + /// Number of replicas (should be 1 for Sonarr) + #[serde(default = "default_replicas")] + pub replicas: i32, + + /// Storage configuration + #[serde(default)] + pub storage: StorageConfig, + + /// Service configuration + #[serde(default)] + pub service: ServiceConfig, + + /// Ingress configuration (optional) + #[serde(default)] + pub ingress: Option, + + /// HTTPRoute configuration for Gateway API (optional) + #[serde(default)] + pub http_route: Option, + + /// Environment variables + #[serde(default)] + pub env: Vec, + + /// Resource requirements + #[serde(default)] + pub resources: Option, + + /// Volume mounts for media directories + #[serde(default)] + pub volume_mounts: Vec, + + /// Additional volumes + #[serde(default)] + pub volumes: Vec, + + /// Node selector + #[serde(default)] + pub node_selector: std::collections::BTreeMap, + + /// Tolerations + #[serde(default)] + pub tolerations: Vec, + + /// Pod security context + #[serde(default)] + pub security_context: Option, + + /// Init container configuration (for custom init logic) + #[serde(default)] + pub init_container: Option, + + /// API key secret reference (optional - will be auto-generated if not provided) + #[serde(default)] + pub api_key_secret_ref: Option, + + /// Sonarr application configuration (config.xml settings) + #[serde(default)] + pub config: SonarrConfig, +} + +/// Configuration for Sonarr's config.xml +/// These settings are applied by the init container on startup +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SonarrConfig { + /// Init container image used to configure config.xml (default: busybox:latest) + #[serde(default)] + pub init_container_image: Option, + + /// URL base for reverse proxy setups (e.g., "/sonarr") + #[serde(default)] + pub url_base: Option, + + /// Bind address (default: "*") + #[serde(default)] + pub bind_address: Option, + + /// Log level: trace, debug, info, warn, error (default: info) + #[serde(default)] + pub log_level: Option, + + /// Instance name displayed in the UI + #[serde(default)] + pub instance_name: Option, + + /// Authentication method: None, Basic, Forms, External (default: None) + #[serde(default)] + pub authentication_method: Option, + + /// Authentication required for API access (default: false) + #[serde(default)] + pub authentication_required: Option, + + /// Analytics enabled (default: true) + #[serde(default)] + pub analytics_enabled: Option, +} + +impl Default for SonarrSpec { + fn default() -> Self { + Self { + image: default_image(), + image_pull_policy: default_image_pull_policy(), + replicas: default_replicas(), + storage: StorageConfig::default(), + service: ServiceConfig::default(), + ingress: None, + http_route: None, + env: Vec::new(), + resources: None, + volume_mounts: Vec::new(), + volumes: Vec::new(), + node_selector: std::collections::BTreeMap::new(), + tolerations: Vec::new(), + security_context: None, + init_container: None, + api_key_secret_ref: None, + config: SonarrConfig::default(), + } + } +} + +fn default_image() -> String { + "lscr.io/linuxserver/sonarr:latest".to_string() +} + +fn default_image_pull_policy() -> String { + "IfNotPresent".to_string() +} + +fn default_replicas() -> i32 { + 1 +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct StorageConfig { + /// Storage class for the PVC + #[serde(default)] + pub storage_class: Option, + + /// Size of the config PVC (default: 1Gi) + #[serde(default = "default_storage_size")] + pub size: String, + + /// Access modes (default: ReadWriteOnce) + #[serde(default = "default_access_modes")] + pub access_modes: Vec, + + /// Existing PVC to use (optional) + #[serde(default)] + pub existing_claim: Option, +} + +fn default_storage_size() -> String { + "1Gi".to_string() +} + +fn default_access_modes() -> Vec { + vec!["ReadWriteOnce".to_string()] +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ServiceConfig { + /// Service type (default: ClusterIP) + #[serde(default = "default_service_type")] + pub service_type: String, + + /// Service port (default: 8989) + #[serde(default = "default_service_port")] + pub port: i32, + + /// Container port - the port Sonarr listens on inside the container (default: 8989) + #[serde(default = "default_container_port")] + pub container_port: i32, + + /// Node port (only for NodePort type) + #[serde(default)] + pub node_port: Option, + + /// Service annotations + #[serde(default)] + pub annotations: std::collections::BTreeMap, +} + +fn default_service_type() -> String { + "ClusterIP".to_string() +} + +fn default_service_port() -> i32 { + 8989 +} + +fn default_container_port() -> i32 { + 8989 +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct IngressConfig { + /// Enable ingress (default: false) + #[serde(default)] + pub enabled: bool, + + /// Ingress class name + #[serde(default)] + pub ingress_class_name: Option, + + /// Hostname for the ingress + pub host: String, + + /// Path for the ingress (default: /) + #[serde(default = "default_ingress_path")] + pub path: String, + + /// Path type (default: Prefix) + #[serde(default = "default_path_type")] + pub path_type: String, + + /// TLS configuration + #[serde(default)] + pub tls: Option, + + /// Ingress annotations + #[serde(default)] + pub annotations: std::collections::BTreeMap, +} + +fn default_ingress_path() -> String { + "/".to_string() +} + +fn default_path_type() -> String { + "Prefix".to_string() +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct IngressTLS { + /// Secret name containing TLS certificate + pub secret_name: String, + + /// Hosts covered by the TLS certificate + #[serde(default)] + pub hosts: Vec, +} + +/// HTTPRoute configuration for Gateway API +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct HTTPRouteConfig { + /// Enable HTTPRoute creation (default: false) + #[serde(default)] + pub enabled: bool, + + /// Gateway reference - the Gateway to attach to + pub gateway_ref: GatewayRef, + + /// Hostnames for the HTTPRoute + #[serde(default)] + pub hostnames: Vec, + + /// Path match for the route (default: /) + #[serde(default = "default_http_route_path")] + pub path: String, + + /// Path match type: Exact, PathPrefix, or RegularExpression (default: PathPrefix) + #[serde(default = "default_http_route_path_type")] + pub path_type: String, + + /// Additional labels for the HTTPRoute + #[serde(default)] + pub labels: std::collections::BTreeMap, + + /// Additional annotations for the HTTPRoute + #[serde(default)] + pub annotations: std::collections::BTreeMap, +} + +/// Gateway reference for HTTPRoute +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct GatewayRef { + /// Name of the Gateway + pub name: String, + + /// Namespace of the Gateway (optional, defaults to same namespace as HTTPRoute) + #[serde(default)] + pub namespace: Option, + + /// Section name within the Gateway (optional) + #[serde(default)] + pub section_name: Option, +} + +fn default_http_route_path() -> String { + "/".to_string() +} + +fn default_http_route_path_type() -> String { + "PathPrefix".to_string() +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct EnvVar { + /// Name of the environment variable + pub name: String, + + /// Value of the environment variable + #[serde(default)] + pub value: Option, + + /// Reference to a secret or configmap + #[serde(default)] + pub value_from: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct EnvVarSource { + /// Secret key reference + #[serde(default)] + pub secret_key_ref: Option, + + /// ConfigMap key reference + #[serde(default)] + pub config_map_key_ref: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ConfigMapKeySelector { + /// Name of the configmap + pub name: String, + + /// Key in the configmap + pub key: String, +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ResourceRequirements { + /// Resource limits + #[serde(default)] + pub limits: std::collections::BTreeMap, + + /// Resource requests + #[serde(default)] + pub requests: std::collections::BTreeMap, +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct VolumeMount { + /// Name of the volume + pub name: String, + + /// Mount path inside the container + pub mount_path: String, + + /// Sub path (optional) + #[serde(default)] + pub sub_path: Option, + + /// Read only flag + #[serde(default)] + pub read_only: bool, +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct Volume { + /// Name of the volume + pub name: String, + + /// PVC claim + #[serde(default)] + pub persistent_volume_claim: Option, + + /// HostPath volume + #[serde(default)] + pub host_path: Option, + + /// NFS volume + #[serde(default)] + pub nfs: Option, + + /// ConfigMap volume + #[serde(default)] + pub config_map: Option, + + /// Empty dir volume + #[serde(default)] + pub empty_dir: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct PersistentVolumeClaimVolumeSource { + pub claim_name: String, + #[serde(default)] + pub read_only: bool, +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct HostPathVolumeSource { + pub path: String, + #[serde(rename = "type", default)] + pub host_path_type: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct NFSVolumeSource { + pub server: String, + pub path: String, + #[serde(default)] + pub read_only: bool, +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ConfigMapVolumeSource { + pub name: String, + #[serde(default)] + pub items: Vec, +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct KeyToPath { + pub key: String, + pub path: String, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct EmptyDirVolumeSource { + #[serde(default)] + pub medium: Option, + #[serde(default)] + pub size_limit: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct Toleration { + #[serde(default)] + pub key: Option, + #[serde(default)] + pub operator: Option, + #[serde(default)] + pub value: Option, + #[serde(default)] + pub effect: Option, + #[serde(default)] + pub toleration_seconds: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct PodSecurityContext { + #[serde(default)] + pub run_as_user: Option, + #[serde(default)] + pub run_as_group: Option, + #[serde(default)] + pub fs_group: Option, + #[serde(default)] + pub run_as_non_root: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct InitContainerConfig { + /// Image for init container (default: busybox:latest) + #[serde(default = "default_init_image")] + pub image: String, + + /// Command to run in init container + #[serde(default)] + pub command: Vec, + + /// Arguments for the command + #[serde(default)] + pub args: Vec, + + /// Environment variables for init container + #[serde(default)] + pub env: Vec, +} + +fn default_init_image() -> String { + "busybox:latest".to_string() +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SonarrStatus { + /// Current conditions + #[serde(default)] + pub conditions: Vec, + + /// URL to access Sonarr + #[serde(default)] + pub url: Option, + + /// API key (stored in secret) + #[serde(default)] + pub api_key_secret: Option, + + /// Observed generation + #[serde(default)] + pub observed_generation: i64, + + /// Number of ready replicas + #[serde(default)] + pub ready_replicas: i32, + + /// Sonarr version + #[serde(default)] + pub version: Option, +} + +impl Sonarr { + /// Get the service port (default: 8989) + pub fn service_port(&self) -> i32 { + if self.spec.service.port == 0 { + 8989 + } else { + self.spec.service.port + } + } + + /// Get the container port (default: 8989) + pub fn container_port(&self) -> i32 { + if self.spec.service.container_port == 0 { + 8989 + } else { + self.spec.service.container_port + } + } + + /// Get the service type (default: ClusterIP) + pub fn service_type(&self) -> String { + if self.spec.service.service_type.is_empty() { + "ClusterIP".to_string() + } else { + self.spec.service.service_type.clone() + } + } + + /// Get the service name for this instance + pub fn service_name(&self) -> String { + format!( + "{}-sonarr", + self.metadata.name.as_deref().unwrap_or("unknown") + ) + } + + /// Get the deployment name for this instance + pub fn deployment_name(&self) -> String { + format!( + "{}-sonarr", + self.metadata.name.as_deref().unwrap_or("unknown") + ) + } + + /// Get the PVC name for this instance + pub fn pvc_name(&self) -> String { + format!( + "{}-sonarr-config", + self.metadata.name.as_deref().unwrap_or("unknown") + ) + } + + /// Get the secret name for API key + pub fn api_key_secret_name(&self) -> String { + format!( + "{}-sonarr-apikey", + self.metadata.name.as_deref().unwrap_or("unknown") + ) + } + + /// Get the internal URL for Sonarr + pub fn internal_url(&self, namespace: &str) -> String { + format!( + "http://{}.{}.svc.cluster.local:{}", + self.service_name(), + namespace, + self.service_port() + ) + } +} diff --git a/src/crds/tag.rs b/src/crds/tag.rs new file mode 100644 index 0000000..f4dd5d5 --- /dev/null +++ b/src/crds/tag.rs @@ -0,0 +1,47 @@ +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::SonarrInstanceRef; + +/// SonarrTag represents a tag in Sonarr +/// Tags are used to organize and filter series, profiles, and other resources +#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[kube( + group = "devopsarr.io", + version = "v1alpha1", + kind = "SonarrTag", + plural = "sonarrtags", + shortname = "stag", + namespaced, + status = "SonarrTagStatus", + printcolumn = r#"{"name":"Label","type":"string","jsonPath":".spec.label"}"#, + printcolumn = r#"{"name":"ID","type":"integer","jsonPath":".status.id"}"#, + printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type==\"Ready\")].status"}"#, + printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"# +)] +#[serde(rename_all = "camelCase")] +pub struct SonarrTagSpec { + /// Reference to the SonarrInstance + pub sonarr_instance_ref: SonarrInstanceRef, + + /// Tag label (must be lowercase) + pub label: String, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SonarrTagStatus { + /// Current conditions + #[serde(default)] + pub conditions: Vec, + + /// Sonarr Tag ID + #[serde(default)] + pub id: Option, + + /// Observed generation + #[serde(default)] + pub observed_generation: i64, +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..2db10d6 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,52 @@ +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum Error { + #[error("Kubernetes API error: {0}")] + KubeError(#[from] kube::Error), + + #[error("Sonarr API error: {0}")] + SonarrApiError(String), + + #[error("Serialization error: {0}")] + SerializationError(#[from] serde_json::Error), + + #[error("Missing object key: {0}")] + MissingObjectKey(&'static str), + + #[error("Missing Sonarr reference: {0}")] + MissingSonarrRef(String), + + #[error("Missing Sonarr API credentials")] + MissingApiCredentials, + + #[error("Sonarr instance not found: {0}")] + SonarrInstanceNotFound(String), + + #[error("Sonarr instance not ready: {0}")] + SonarrInstanceNotReady(String), + + #[error("Invalid configuration: {0}")] + InvalidConfiguration(String), + + #[error("Finalizer error: {0}")] + FinalizerError(#[source] Box>), + + #[error("{0}")] + Other(String), +} + +pub type Result = std::result::Result; + +impl Error { + pub fn missing_object_key(key: &'static str) -> Self { + Error::MissingObjectKey(key) + } +} + +/// Convert sonarr API errors to our error type +impl From> for Error { + fn from(err: sonarr::apis::Error) -> Self { + Error::SonarrApiError(format!("{:?}", err)) + } +} diff --git a/src/error/mod.rs b/src/error/mod.rs deleted file mode 100644 index e434704..0000000 --- a/src/error/mod.rs +++ /dev/null @@ -1,14 +0,0 @@ -use kube::Error as KubeError; -use thiserror::Error; - -#[derive(Error, Debug)] -pub enum Error { - #[error("Kubernetes API error: {0}")] - KubeError(#[from] KubeError), - - #[error("Serde JSON error: {0}")] - SerdeJsonError(#[from] serde_json::Error), - - #[error("Invalid spec: {0}")] - InvalidSpec(String), -} diff --git a/src/lib.rs b/src/lib.rs index 5838e1c..3a3a5ac 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,23 @@ -// File: src/lib.rs +//! Sonarr Kubernetes Operator Library +//! +//! This library provides the core types and functionality for the Sonarr Kubernetes Operator. +//! It exposes CRDs, controllers, and API clients for managing Sonarr instances via Kubernetes. -// Re-export the crd module for use in the generate_crd binary -pub mod crd; -pub mod controller; -pub mod error; \ No newline at end of file +pub mod api; +pub mod controllers; +pub mod crds; +pub mod error; + +pub use error::{Error, Result}; + +use std::sync::Arc; +use kube::Client; +use crate::api::SonarrClientFactory; + +/// Shared context for all controllers +pub struct Context { + /// Kubernetes client + pub client: Client, + /// Sonarr API client factory + pub sonarr_client_factory: Arc, +} diff --git a/src/main.rs b/src/main.rs index e82121a..c220fb8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,75 +1,91 @@ -use anyhow::Result; -use clap::Parser; -use futures::StreamExt; -use kube::{ - runtime::{controller::Controller, watcher::Config}, - Client, -}; +use kube::Client; use std::sync::Arc; -use tokio::signal; -use tracing::{error, info, Level}; -use tracing_subscriber::{fmt, EnvFilter}; +use tracing::{error, info}; -mod controller; -mod crd; -mod error; - -use controller::reconciler::{error_policy, reconcile, SonarrState}; -use crd::sonarr::Sonarr; - -#[derive(Parser, Debug)] -#[clap(author, version, about, long_about = None)] -struct Opts { - #[arg(long)] - namespace: Option, -} +use sonarr_operator::{Context, api, controllers}; #[tokio::main] -async fn main() -> Result<()> { - // Initialize logging - fmt() - .with_env_filter(EnvFilter::from_default_env().add_directive(Level::INFO.into())) - .with_writer(std::io::stdout) // Forza l'output su stdout +async fn main() -> anyhow::Result<()> { + // Install the ring crypto provider for rustls + rustls::crypto::ring::default_provider() + .install_default() + .expect("Failed to install rustls crypto provider"); + + // Initialize tracing + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "sonarr_operator=info,kube=info".into()), + ) + .json() .init(); - info!("Starting rust-k8s-operator"); - // Parse command-line arguments - let opts = Opts::parse(); + info!("Starting Sonarr Kubernetes Operator"); // Create Kubernetes client let client = Client::try_default().await?; - info!("Successfully connected to Kubernetes cluster"); - - // Shared state for the controller - let state = Arc::new(SonarrState::new(client.clone())); + info!("Connected to Kubernetes cluster"); + + // Create shared context + let context = Arc::new(Context { + client: client.clone(), + sonarr_client_factory: Arc::new(api::SonarrClientFactory::new()), + }); + + // Start all controllers concurrently + let sonarr_controller = controllers::sonarr::run(client.clone(), context.clone()); + let tag_controller = controllers::tag::run(client.clone(), context.clone()); + let root_folder_controller = controllers::root_folder::run(client.clone(), context.clone()); + let quality_profile_controller = + controllers::quality_profile::run(client.clone(), context.clone()); + let indexer_controller = controllers::indexer::run(client.clone(), context.clone()); + let download_client_controller = + controllers::download_client::run(client.clone(), context.clone()); + let notification_controller = controllers::notification::run(client.clone(), context.clone()); + let series_controller = controllers::series::run(client.clone(), context.clone()); + let import_list_controller = controllers::import_list::run(client.clone(), context.clone()); + let language_profile_controller = + controllers::language_profile::run(client.clone(), context.clone()); + let metadata_controller = controllers::metadata::run(client.clone(), context.clone()); + let custom_format_controller = controllers::custom_format::run(client.clone(), context.clone()); + let delay_profile_controller = controllers::delay_profile::run(client.clone(), context.clone()); + let quality_definition_controller = + controllers::quality_definition::run(client.clone(), context.clone()); + let auto_tag_controller = controllers::auto_tag::run(client.clone(), context.clone()); - // Set up controller for our custom resource based on namespace option - let sonarr_api = if let Some(namespace) = opts.namespace { - info!("Watching Sonarr resources in namespace: {}", namespace); - kube::Api::::namespaced(client.clone(), &namespace) - } else { - info!("Watching Sonarr resources across all namespaces"); - kube::Api::::all(client.clone()) - }; + // Config controllers (singleton per Sonarr instance) + let media_management_config_controller = + controllers::media_management_config::run(client.clone(), context.clone()); + let naming_config_controller = controllers::naming_config::run(client.clone(), context.clone()); + let indexer_config_controller = + controllers::indexer_config::run(client.clone(), context.clone()); + let download_client_config_controller = + controllers::download_client_config::run(client.clone(), context.clone()); - // Run the controller - Controller::new(sonarr_api, Config::default()) - .run(reconcile, error_policy, state) - .for_each(|res| async move { - match res { - Ok(o) => { - info!("Reconciled: {:?}", o); - } - Err(e) => { - error!("Reconcile error: {:?}", e); - } - } - }) - .await; + info!("All controllers started"); - // Wait for SIGTERM - signal::ctrl_c().await?; - info!("Shutting down rust-k8s-operator"); + // Run all controllers + tokio::select! { + _ = sonarr_controller => error!("Sonarr controller exited"), + _ = tag_controller => error!("Tag controller exited"), + _ = root_folder_controller => error!("Root folder controller exited"), + _ = quality_profile_controller => error!("Quality profile controller exited"), + _ = indexer_controller => error!("Indexer controller exited"), + _ = download_client_controller => error!("Download client controller exited"), + _ = notification_controller => error!("Notification controller exited"), + _ = series_controller => error!("Series controller exited"), + _ = import_list_controller => error!("Import list controller exited"), + _ = language_profile_controller => error!("Language profile controller exited"), + _ = metadata_controller => error!("Metadata controller exited"), + _ = custom_format_controller => error!("Custom format controller exited"), + _ = delay_profile_controller => error!("Delay profile controller exited"), + _ = quality_definition_controller => error!("Quality definition controller exited"), + _ = auto_tag_controller => error!("Auto tag controller exited"), + _ = media_management_config_controller => error!("Media management config controller exited"), + _ = naming_config_controller => error!("Naming config controller exited"), + _ = indexer_config_controller => error!("Indexer config controller exited"), + _ = download_client_config_controller => error!("Download client config controller exited"), + } Ok(()) } diff --git a/tests/e2e/common.rs b/tests/e2e/common.rs new file mode 100644 index 0000000..fe67f8a --- /dev/null +++ b/tests/e2e/common.rs @@ -0,0 +1,489 @@ +//! Common utilities for E2E tests + +use k8s_openapi::api::core::v1::{Namespace, Secret}; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::{ + Client, Resource, + api::{Api, DeleteParams, Patch, PatchParams}, +}; +use std::collections::BTreeMap; +use std::time::Duration; +use tokio::time::timeout; + +/// E2E test namespace +pub const E2E_NAMESPACE: &str = "sonarr-e2e-test"; + +/// Default timeout for E2E operations (longer than integration tests) +pub const E2E_TIMEOUT: Duration = Duration::from_secs(120); + +/// Short timeout for quick checks +pub const QUICK_TIMEOUT: Duration = Duration::from_secs(30); + +/// Sonarr service details for E2E tests +pub const SONARR_SERVICE_NAME: &str = "sonarr"; +pub const SONARR_SERVICE_PORT: u16 = 8989; + +/// Create a Kubernetes client +pub async fn e2e_client() -> Client { + Client::try_default() + .await + .expect("Failed to create Kubernetes client - is your kubeconfig configured?") +} + +/// Setup the E2E test namespace with Sonarr connection secret +pub async fn setup_e2e_namespace(client: &Client) -> Result<(), anyhow::Error> { + // Create namespace + let namespaces: Api = Api::all(client.clone()); + let ns = Namespace { + metadata: ObjectMeta { + name: Some(E2E_NAMESPACE.to_string()), + labels: Some(BTreeMap::from([ + ( + "app.kubernetes.io/managed-by".to_string(), + "sonarr-e2e".to_string(), + ), + ("test-type".to_string(), "e2e".to_string()), + ])), + ..Default::default() + }, + ..Default::default() + }; + + let patch_params = PatchParams::apply("sonarr-e2e-test").force(); + namespaces + .patch(E2E_NAMESPACE, &patch_params, &Patch::Apply(&ns)) + .await?; + + // Wait for namespace to be active + tokio::time::sleep(Duration::from_secs(1)).await; + + Ok(()) +} + +/// Create the Sonarr connection secret for tests +pub async fn create_sonarr_secret( + client: &Client, + api_key: &str, + url: &str, +) -> Result<(), anyhow::Error> { + let secrets: Api = Api::namespaced(client.clone(), E2E_NAMESPACE); + + let secret = Secret { + metadata: ObjectMeta { + name: Some("sonarr-api-key".to_string()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + string_data: Some(BTreeMap::from([ + ("api-key".to_string(), api_key.to_string()), + ("url".to_string(), url.to_string()), + ])), + ..Default::default() + }; + + let patch_params = PatchParams::apply("sonarr-e2e-test").force(); + secrets + .patch("sonarr-api-key", &patch_params, &Patch::Apply(&secret)) + .await?; + + Ok(()) +} + +/// Cleanup the E2E test namespace +pub async fn cleanup_e2e_namespace(client: &Client) -> Result<(), anyhow::Error> { + let namespaces: Api = Api::all(client.clone()); + + match namespaces + .delete(E2E_NAMESPACE, &DeleteParams::default()) + .await + { + Ok(_) => { + // Wait for namespace deletion + let _ = timeout(Duration::from_secs(60), async { + loop { + match namespaces.get(E2E_NAMESPACE).await { + Err(_) => break, + Ok(_) => tokio::time::sleep(Duration::from_secs(1)).await, + } + } + }) + .await; + Ok(()) + } + Err(kube::Error::Api(err)) if err.code == 404 => Ok(()), + Err(e) => Err(e.into()), + } +} + +/// Apply a namespaced resource +pub async fn apply_resource(client: &Client, resource: &T) -> Result +where + T: Resource + + Clone + + serde::Serialize + + serde::de::DeserializeOwned + + std::fmt::Debug, + ::DynamicType: Default, +{ + let namespace = resource + .meta() + .namespace + .as_deref() + .unwrap_or(E2E_NAMESPACE); + let api: Api = Api::namespaced(client.clone(), namespace); + let name = resource.meta().name.clone().unwrap_or_default(); + + let patch_params = PatchParams::apply("sonarr-e2e-test").force(); + api.patch(&name, &patch_params, &Patch::Apply(resource)) + .await +} + +/// Delete a namespaced resource +pub async fn delete_resource( + client: &Client, + namespace: &str, + name: &str, +) -> Result<(), kube::Error> +where + T: Resource + + Clone + + serde::Serialize + + serde::de::DeserializeOwned + + std::fmt::Debug, + ::DynamicType: Default, +{ + let api: Api = Api::namespaced(client.clone(), namespace); + + match api.delete(name, &DeleteParams::default()).await { + Ok(_) => Ok(()), + Err(kube::Error::Api(err)) if err.code == 404 => Ok(()), + Err(e) => Err(e), + } +} + +/// Wait for a resource to have a Ready condition +pub async fn wait_for_ready( + client: &Client, + namespace: &str, + name: &str, + timeout_duration: Duration, +) -> Result +where + T: Resource + + Clone + + serde::Serialize + + serde::de::DeserializeOwned + + std::fmt::Debug + + HasConditions, + ::DynamicType: Default, +{ + let api: Api = Api::namespaced(client.clone(), namespace); + + timeout(timeout_duration, async { + loop { + match api.get(name).await { + Ok(resource) => { + if resource.is_ready() { + return Ok(resource); + } + // Check for error conditions + if let Some(msg) = resource.get_error_message() { + return Err(anyhow::anyhow!("Resource has error condition: {}", msg)); + } + } + Err(e) => { + tracing::debug!("Waiting for resource {}: {:?}", name, e); + } + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + }) + .await + .map_err(|_| anyhow::anyhow!("Timeout waiting for {} to be ready", name))? +} + +/// Wait for a resource to be deleted +pub async fn wait_for_deletion( + client: &Client, + namespace: &str, + name: &str, + timeout_duration: Duration, +) -> Result<(), anyhow::Error> +where + T: Resource + + Clone + + serde::Serialize + + serde::de::DeserializeOwned + + std::fmt::Debug, + ::DynamicType: Default, +{ + let api: Api = Api::namespaced(client.clone(), namespace); + + timeout(timeout_duration, async { + loop { + match api.get(name).await { + Err(kube::Error::Api(err)) if err.code == 404 => { + return Ok(()); + } + _ => { + tokio::time::sleep(Duration::from_millis(500)).await; + } + } + } + }) + .await + .map_err(|_| anyhow::anyhow!("Timeout waiting for {} to be deleted", name))? +} + +/// Trait for resources that have conditions in their status +pub trait HasConditions { + fn is_ready(&self) -> bool; + fn get_error_message(&self) -> Option; +} + +/// Generate a unique test name +pub fn unique_name(prefix: &str) -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis(); + format!("{}-{}", prefix, timestamp % 100000) +} + +/// Test context that tracks resources for cleanup +pub struct TestContext { + pub client: Client, + pub sonarr: super::SonarrTestClient, + cleanup_resources: Vec, +} + +struct CleanupResource { + kind: String, + namespace: String, + name: String, +} + +impl TestContext { + pub async fn new() -> Result { + let client = e2e_client().await; + + // Get Sonarr URL and API key from environment or port-forward + let sonarr_url = std::env::var("SONARR_URL") + .unwrap_or_else(|_| format!("http://{}:{}", SONARR_SERVICE_NAME, SONARR_SERVICE_PORT)); + let sonarr_api_key = std::env::var("SONARR_API_KEY") + .expect("SONARR_API_KEY environment variable must be set for E2E tests"); + + let sonarr = super::SonarrTestClient::new(&sonarr_url, &sonarr_api_key)?; + + Ok(Self { + client, + sonarr, + cleanup_resources: Vec::new(), + }) + } + + /// Register a resource for cleanup at the end of the test + pub fn register_cleanup(&mut self, kind: &str, namespace: &str, name: &str) { + self.cleanup_resources.push(CleanupResource { + kind: kind.to_string(), + namespace: namespace.to_string(), + name: name.to_string(), + }); + } + + /// Cleanup all registered resources (called automatically on drop or explicitly) + pub async fn cleanup(&mut self) { + use sonarr_operator::crds::*; + + // Cleanup in reverse order (dependencies last) + for resource in self.cleanup_resources.drain(..).rev() { + tracing::info!( + "Cleaning up {} {}/{}", + resource.kind, + resource.namespace, + resource.name + ); + + let result = match resource.kind.as_str() { + "SonarrTag" => { + delete_resource::(&self.client, &resource.namespace, &resource.name) + .await + } + "SonarrRootFolder" => { + delete_resource::( + &self.client, + &resource.namespace, + &resource.name, + ) + .await + } + "SonarrQualityProfile" => { + delete_resource::( + &self.client, + &resource.namespace, + &resource.name, + ) + .await + } + "SonarrQualityDefinition" => { + delete_resource::( + &self.client, + &resource.namespace, + &resource.name, + ) + .await + } + "SonarrAutoTag" => { + delete_resource::( + &self.client, + &resource.namespace, + &resource.name, + ) + .await + } + "SonarrCustomFormat" => { + delete_resource::( + &self.client, + &resource.namespace, + &resource.name, + ) + .await + } + "SonarrDelayProfile" => { + delete_resource::( + &self.client, + &resource.namespace, + &resource.name, + ) + .await + } + "SonarrNotification" => { + delete_resource::( + &self.client, + &resource.namespace, + &resource.name, + ) + .await + } + "SonarrIndexer" => { + delete_resource::( + &self.client, + &resource.namespace, + &resource.name, + ) + .await + } + "SonarrDownloadClient" => { + delete_resource::( + &self.client, + &resource.namespace, + &resource.name, + ) + .await + } + "SonarrMediaManagementConfig" => { + delete_resource::( + &self.client, + &resource.namespace, + &resource.name, + ) + .await + } + "SonarrNamingConfig" => { + delete_resource::( + &self.client, + &resource.namespace, + &resource.name, + ) + .await + } + "SonarrIndexerConfig" => { + delete_resource::( + &self.client, + &resource.namespace, + &resource.name, + ) + .await + } + "SonarrDownloadClientConfig" => { + delete_resource::( + &self.client, + &resource.namespace, + &resource.name, + ) + .await + } + _ => { + tracing::warn!("Unknown resource kind for cleanup: {}", resource.kind); + Ok(()) + } + }; + + if let Err(e) = result { + tracing::warn!( + "Failed to cleanup {} {}: {:?}", + resource.kind, + resource.name, + e + ); + } + } + } +} + +impl Drop for TestContext { + fn drop(&mut self) { + // Note: async cleanup in drop is tricky, recommend calling cleanup() explicitly + if !self.cleanup_resources.is_empty() { + tracing::warn!( + "TestContext dropped with {} resources not cleaned up. Call cleanup() explicitly.", + self.cleanup_resources.len() + ); + } + } +} + +// Implement HasConditions for our CRDs +macro_rules! impl_has_conditions { + ($type:ty) => { + impl HasConditions for $type { + fn is_ready(&self) -> bool { + self.status + .as_ref() + .map(|s| { + s.conditions + .iter() + .any(|c| c.type_ == "Ready" && c.status == "True") + }) + .unwrap_or(false) + } + + fn get_error_message(&self) -> Option { + self.status.as_ref().and_then(|s| { + s.conditions + .iter() + .find(|c| c.type_ == "Ready" && c.status == "False") + .map(|c| c.message.clone()) + }) + } + } + }; +} + +use sonarr_operator::crds::*; + +impl_has_conditions!(SonarrTag); +impl_has_conditions!(SonarrRootFolder); +impl_has_conditions!(SonarrQualityProfile); +impl_has_conditions!(SonarrAutoTag); +impl_has_conditions!(SonarrCustomFormat); +impl_has_conditions!(SonarrDelayProfile); +impl_has_conditions!(SonarrNotification); +impl_has_conditions!(SonarrIndexer); +impl_has_conditions!(SonarrDownloadClient); +impl_has_conditions!(SonarrMediaManagementConfig); +impl_has_conditions!(SonarrNamingConfig); +impl_has_conditions!(SonarrIndexerConfig); +impl_has_conditions!(SonarrDownloadClientConfig); +impl_has_conditions!(SonarrQualityDefinition); diff --git a/tests/e2e/fixtures/01-sonarr-instance.yaml b/tests/e2e/fixtures/01-sonarr-instance.yaml new file mode 100644 index 0000000..e2f13ea --- /dev/null +++ b/tests/e2e/fixtures/01-sonarr-instance.yaml @@ -0,0 +1,7 @@ +apiVersion: devopsarr.io/v1alpha1 +kind: Sonarr +metadata: + name: sonarr + namespace: default +spec: + image: lscr.io/linuxserver/sonarr:latest diff --git a/tests/e2e/fixtures/02-tags.yaml b/tests/e2e/fixtures/02-tags.yaml new file mode 100644 index 0000000..458c808 --- /dev/null +++ b/tests/e2e/fixtures/02-tags.yaml @@ -0,0 +1,30 @@ +# Test SonarrTag resources +apiVersion: devopsarr.io/v1alpha1 +kind: SonarrTag +metadata: + name: test-tag-anime + namespace: sonarr-test +spec: + sonarrInstanceRef: + name: test-sonarr + label: 'anime' +--- +apiVersion: devopsarr.io/v1alpha1 +kind: SonarrTag +metadata: + name: test-tag-documentary + namespace: sonarr-test +spec: + sonarrInstanceRef: + name: test-sonarr + label: 'documentary' +--- +apiVersion: devopsarr.io/v1alpha1 +kind: SonarrTag +metadata: + name: test-tag-kids + namespace: sonarr-test +spec: + sonarrInstanceRef: + name: test-sonarr + label: 'kids' diff --git a/tests/e2e/fixtures/03-root-folders.yaml b/tests/e2e/fixtures/03-root-folders.yaml new file mode 100644 index 0000000..4e7f3f9 --- /dev/null +++ b/tests/e2e/fixtures/03-root-folders.yaml @@ -0,0 +1,20 @@ +# Test SonarrRootFolder resources +apiVersion: devopsarr.io/v1alpha1 +kind: SonarrRootFolder +metadata: + name: test-root-tv + namespace: sonarr-test +spec: + sonarrInstanceRef: + name: test-sonarr + path: '/tv' +--- +apiVersion: devopsarr.io/v1alpha1 +kind: SonarrRootFolder +metadata: + name: test-root-anime + namespace: sonarr-test +spec: + sonarrInstanceRef: + name: test-sonarr + path: '/anime' diff --git a/tests/e2e/fixtures/cleanup.sh b/tests/e2e/fixtures/cleanup.sh new file mode 100644 index 0000000..5ab75a6 --- /dev/null +++ b/tests/e2e/fixtures/cleanup.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# Cleanup script for E2E tests + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +NAMESPACE="${NAMESPACE:-default}" + +echo "=== Sonarr E2E Test Cleanup ===" + +# Delete test resources +echo "Cleaning up test resources..." +kubectl delete -f "${SCRIPT_DIR}/sonarr-instance.yaml" --ignore-not-found +kubectl delete secret sonarr-api-key -n "${NAMESPACE}" --ignore-not-found + +# Delete E2E test namespace +echo "Cleaning up E2E test namespace..." +kubectl delete namespace sonarr-e2e-test --ignore-not-found + +# Delete Sonarr deployment +echo "Cleaning up Sonarr deployment..." +kubectl delete -f "${SCRIPT_DIR}/sonarr-deployment.yaml" --ignore-not-found + +# Clean up local files +rm -f "${SCRIPT_DIR}/.sonarr-api-key" + +echo "=== Cleanup Complete ===" diff --git a/tests/e2e/fixtures/setup.sh b/tests/e2e/fixtures/setup.sh new file mode 100644 index 0000000..4e57f72 --- /dev/null +++ b/tests/e2e/fixtures/setup.sh @@ -0,0 +1,102 @@ +#!/bin/bash +# Setup script for E2E tests +# This script: +# 1. Deploys Sonarr +# 2. Waits for it to be ready +# 3. Extracts the API key +# 4. Creates the Sonarr CRD instance + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +NAMESPACE="${NAMESPACE:-default}" +TIMEOUT="${TIMEOUT:-300}" + +echo "=== Sonarr E2E Test Setup ===" + +# Step 1: Deploy Sonarr +echo "Step 1: Deploying Sonarr..." +kubectl apply -f "${SCRIPT_DIR}/sonarr-deployment.yaml" + +# Step 2: Wait for Sonarr to be ready +echo "Step 2: Waiting for Sonarr deployment to be ready..." +kubectl wait --for=condition=available deployment/sonarr -n "${NAMESPACE}" --timeout="${TIMEOUT}s" + +echo "Waiting for Sonarr pod to be ready..." +kubectl wait --for=condition=ready pod -l app=sonarr -n "${NAMESPACE}" --timeout="${TIMEOUT}s" + +# Give Sonarr time to initialize and generate API key +echo "Waiting for Sonarr to initialize..." +sleep 30 + +# Step 3: Extract API key from Sonarr config +echo "Step 3: Extracting API key from Sonarr..." +SONARR_POD=$(kubectl get pod -l app=sonarr -n "${NAMESPACE}" -o jsonpath='{.items[0].metadata.name}') + +# Try to get API key from config file +API_KEY="" +for i in {1..10}; do + API_KEY=$(kubectl exec "${SONARR_POD}" -n "${NAMESPACE}" -- cat /config/config.xml 2>/dev/null | grep -oP '(?<=)[^<]+' || true) + if [ -n "${API_KEY}" ]; then + break + fi + echo "Waiting for API key to be generated... (attempt $i/10)" + sleep 10 +done + +if [ -z "${API_KEY}" ]; then + echo "ERROR: Failed to extract API key from Sonarr" + echo "Trying alternative method..." + + # Try to get it from the API directly (requires port-forward) + kubectl port-forward "pod/${SONARR_POD}" 8989:8989 -n "${NAMESPACE}" & + PF_PID=$! + sleep 5 + + # Check if Sonarr is responding + curl -s http://localhost:8989/api/v3/system/status || true + + kill $PF_PID 2>/dev/null || true + + echo "Please check Sonarr logs for issues:" + kubectl logs "${SONARR_POD}" -n "${NAMESPACE}" --tail=50 + exit 1 +fi + +echo "API key extracted successfully" + +# Step 4: Update the secret with the real API key +echo "Step 4: Creating API key secret..." +kubectl create secret generic sonarr-api-key \ + --from-literal=api-key="${API_KEY}" \ + -n "${NAMESPACE}" \ + --dry-run=client -o yaml | kubectl apply -f - + +# Step 5: Create the Sonarr CRD instance +echo "Step 5: Creating Sonarr CRD instance..." +kubectl apply -f "${SCRIPT_DIR}/sonarr-instance.yaml" + +# Step 6: Export environment variables for tests +echo "" +echo "=== Setup Complete ===" +echo "" +echo "Sonarr is ready for E2E tests!" +echo "" +echo "To run E2E tests locally, set these environment variables:" +echo "" +echo " export SONARR_API_KEY='${API_KEY}'" +echo " export SONARR_URL='http://localhost:8989'" +echo "" +echo "Then port-forward Sonarr:" +echo " kubectl port-forward svc/sonarr 8989:8989 -n ${NAMESPACE}" +echo "" +echo "And run the operator:" +echo " cargo run" +echo "" +echo "Finally, run E2E tests:" +echo " cargo test --test e2e -- --ignored --test-threads=1" +echo "" + +# Create a file with the API key for CI usage +echo "${API_KEY}" > "${SCRIPT_DIR}/.sonarr-api-key" +echo "API key saved to ${SCRIPT_DIR}/.sonarr-api-key" diff --git a/tests/e2e/fixtures/sonarr-deployment.yaml b/tests/e2e/fixtures/sonarr-deployment.yaml new file mode 100644 index 0000000..19f45e5 --- /dev/null +++ b/tests/e2e/fixtures/sonarr-deployment.yaml @@ -0,0 +1,114 @@ +# Sonarr deployment for E2E tests +# This deploys a Sonarr instance that the operator will manage +--- +apiVersion: v1 +kind: Namespace +metadata: + name: default + labels: + app.kubernetes.io/managed-by: sonarr-e2e +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: sonarr-config + namespace: default +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: sonarr-data + namespace: default +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: sonarr + namespace: default + labels: + app: sonarr +spec: + replicas: 1 + selector: + matchLabels: + app: sonarr + template: + metadata: + labels: + app: sonarr + spec: + containers: + - name: sonarr + image: lscr.io/linuxserver/sonarr:latest + ports: + - containerPort: 8989 + name: http + env: + - name: PUID + value: '1000' + - name: PGID + value: '1000' + - name: TZ + value: 'UTC' + volumeMounts: + - name: config + mountPath: /config + - name: data + mountPath: /data + resources: + requests: + memory: '256Mi' + cpu: '100m' + limits: + memory: '512Mi' + cpu: '500m' + readinessProbe: + httpGet: + path: /api/v3/system/status + port: 8989 + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + livenessProbe: + httpGet: + path: /api/v3/system/status + port: 8989 + initialDelaySeconds: 60 + periodSeconds: 30 + timeoutSeconds: 10 + volumes: + - name: config + persistentVolumeClaim: + claimName: sonarr-config + - name: data + persistentVolumeClaim: + claimName: sonarr-data +--- +apiVersion: v1 +kind: Service +metadata: + name: sonarr + namespace: default + labels: + app: sonarr +spec: + type: ClusterIP + ports: + - port: 8989 + targetPort: 8989 + protocol: TCP + name: http + selector: + app: sonarr diff --git a/tests/e2e/fixtures/sonarr-instance.yaml b/tests/e2e/fixtures/sonarr-instance.yaml new file mode 100644 index 0000000..0ba85be --- /dev/null +++ b/tests/e2e/fixtures/sonarr-instance.yaml @@ -0,0 +1,28 @@ +# Sonarr CRD instance for E2E tests +# This creates the Sonarr custom resource that the operator will use to connect +--- +apiVersion: v1 +kind: Secret +metadata: + name: sonarr-api-key + namespace: default + labels: + app: sonarr +type: Opaque +stringData: + # This will be populated by the setup script after Sonarr starts + # The API key is generated by Sonarr on first run + api-key: 'placeholder-will-be-replaced' +--- +apiVersion: devopsarr.io/v1alpha1 +kind: Sonarr +metadata: + name: sonarr + namespace: default +spec: + # URL of the Sonarr instance + url: http://sonarr.default.svc.cluster.local:8989 + # Reference to the secret containing the API key + apiKeySecretRef: + name: sonarr-api-key + key: api-key diff --git a/tests/e2e/main.rs b/tests/e2e/main.rs new file mode 100644 index 0000000..de13ab9 --- /dev/null +++ b/tests/e2e/main.rs @@ -0,0 +1,23 @@ +//! End-to-End tests for the Sonarr Kubernetes Operator +//! +//! These tests verify the full reconciliation loop by: +//! 1. Running the operator against a real Kubernetes cluster +//! 2. Creating CRs and verifying resources are created in Sonarr +//! 3. Testing cross-resource dependencies +//! 4. Verifying cleanup on deletion +//! +//! Prerequisites: +//! 1. A running Kubernetes cluster (e.g., kind, k3d) +//! 2. CRDs installed: `make install` +//! 3. Sonarr instance deployed: `make e2e-sonarr` +//! 4. Operator running: `make run` (in background) +//! +//! Run with: `cargo test --test e2e -- --ignored --test-threads=1` + +mod common; +mod scenarios; +mod sonarr_client; + +// Re-export for use in scenario tests +pub use common::*; +pub use sonarr_client::SonarrTestClient; diff --git a/tests/e2e/scenarios/config_resources.rs b/tests/e2e/scenarios/config_resources.rs new file mode 100644 index 0000000..a53800f --- /dev/null +++ b/tests/e2e/scenarios/config_resources.rs @@ -0,0 +1,260 @@ +//! E2E tests for config resources (singleton resources) +//! +//! Tests for MediaManagementConfig, NamingConfig, IndexerConfig, DownloadClientConfig + +use crate::common::*; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::api::{Api, Patch, PatchParams}; +use sonarr_operator::crds::*; +use std::time::Duration; + +/// Test MediaManagementConfig updates Sonarr settings +#[tokio::test] +#[ignore = "requires E2E environment - run with: cargo test --test e2e -- --ignored"] +async fn test_media_management_config_update() { + let mut ctx = TestContext::new().await.expect("Failed to create test context"); + setup_e2e_namespace(&ctx.client).await.expect("Failed to setup namespace"); + + // Get current config from Sonarr for comparison + let original_config = ctx.sonarr + .get_media_management_config() + .await + .expect("Failed to get current media management config"); + tracing::info!("Original config: recycle_bin_cleanup_days = {}", original_config.recycle_bin_cleanup_days); + + // Create MediaManagementConfig CR + let config_name = unique_name("e2e-mmc"); + ctx.register_cleanup("SonarrMediaManagementConfig", E2E_NAMESPACE, &config_name); + + let new_cleanup_days = if original_config.recycle_bin_cleanup_days == 7 { 14 } else { 7 }; + + let config = SonarrMediaManagementConfig { + metadata: ObjectMeta { + name: Some(config_name.clone()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrMediaManagementConfigSpec { + recycle_bin_cleanup_days: Some(new_cleanup_days), + create_empty_series_folders: Some(true), + delete_empty_folders: Some(true), + sonarr_instance_ref: SonarrInstanceRef { + name: "sonarr".to_string(), + namespace: Some("default".to_string()), + }, + ..Default::default() + }, + status: None, + }; + + apply_resource(&ctx.client, &config).await.expect("Failed to create config"); + + // Wait for Ready + wait_for_ready::( + &ctx.client, + E2E_NAMESPACE, + &config_name, + E2E_TIMEOUT, + ) + .await + .expect("Config never became ready"); + + // Give Sonarr time to apply the change + tokio::time::sleep(Duration::from_secs(3)).await; + + // Verify in Sonarr + let updated_config = ctx.sonarr + .get_media_management_config() + .await + .expect("Failed to get updated config"); + + assert_eq!( + updated_config.recycle_bin_cleanup_days, new_cleanup_days, + "Recycle bin cleanup days should be updated" + ); + assert!(updated_config.create_empty_series_folders, "Create empty folders should be true"); + assert!(updated_config.delete_empty_folders, "Delete empty folders should be true"); + tracing::info!("✓ MediaManagementConfig verified in Sonarr"); + + // Restore original value + let api: Api = Api::namespaced(ctx.client.clone(), E2E_NAMESPACE); + let restore_patch = serde_json::json!({ + "apiVersion": "devopsarr.io/v1alpha1", + "kind": "SonarrMediaManagementConfig", + "metadata": { + "name": config_name, + "namespace": E2E_NAMESPACE + }, + "spec": { + "recycleBinCleanupDays": original_config.recycle_bin_cleanup_days, + "sonarrInstanceRef": { + "name": "sonarr", + "namespace": "default" + } + } + }); + + api.patch( + &config_name, + &PatchParams::apply("sonarr-e2e-test").force(), + &Patch::Apply(&restore_patch), + ) + .await + .expect("Failed to restore config"); + + tokio::time::sleep(Duration::from_secs(3)).await; + tracing::info!("✓ Config restored to original values"); + + ctx.cleanup().await; +} + +/// Test NamingConfig updates episode naming settings +#[tokio::test] +#[ignore = "requires E2E environment - run with: cargo test --test e2e -- --ignored"] +async fn test_naming_config_update() { + let mut ctx = TestContext::new().await.expect("Failed to create test context"); + setup_e2e_namespace(&ctx.client).await.expect("Failed to setup namespace"); + + // Get current config + let original_config = ctx.sonarr + .get_naming_config() + .await + .expect("Failed to get current naming config"); + tracing::info!("Original config: rename_episodes = {}", original_config.rename_episodes); + + let config_name = unique_name("e2e-nc"); + ctx.register_cleanup("SonarrNamingConfig", E2E_NAMESPACE, &config_name); + + // Create config with specific naming format + let config = SonarrNamingConfig { + metadata: ObjectMeta { + name: Some(config_name.clone()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrNamingConfigSpec { + rename_episodes: Some(true), + replace_illegal_characters: Some(true), + standard_episode_format: Some("{Series Title} - S{season:00}E{episode:00} - {Episode Title}".to_string()), + season_folder_format: Some("Season {season}".to_string()), + sonarr_instance_ref: SonarrInstanceRef { + name: "sonarr".to_string(), + namespace: Some("default".to_string()), + }, + ..Default::default() + }, + status: None, + }; + + apply_resource(&ctx.client, &config).await.expect("Failed to create naming config"); + + wait_for_ready::( + &ctx.client, + E2E_NAMESPACE, + &config_name, + E2E_TIMEOUT, + ) + .await + .expect("NamingConfig never became ready"); + + tokio::time::sleep(Duration::from_secs(3)).await; + + let updated_config = ctx.sonarr + .get_naming_config() + .await + .expect("Failed to get updated naming config"); + + assert!(updated_config.rename_episodes, "Rename episodes should be enabled"); + assert!(updated_config.replace_illegal_characters, "Replace illegal chars should be enabled"); + tracing::info!("✓ NamingConfig verified in Sonarr"); + + ctx.cleanup().await; +} + +/// Test singleton constraint - only one config per instance +#[tokio::test] +#[ignore = "requires E2E environment - run with: cargo test --test e2e -- --ignored"] +async fn test_config_singleton_constraint() { + let mut ctx = TestContext::new().await.expect("Failed to create test context"); + setup_e2e_namespace(&ctx.client).await.expect("Failed to setup namespace"); + + // Create first MediaManagementConfig + let config1_name = unique_name("e2e-mmc-1"); + ctx.register_cleanup("SonarrMediaManagementConfig", E2E_NAMESPACE, &config1_name); + + let config1 = SonarrMediaManagementConfig { + metadata: ObjectMeta { + name: Some(config1_name.clone()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrMediaManagementConfigSpec { + recycle_bin_cleanup_days: Some(7), + sonarr_instance_ref: SonarrInstanceRef { + name: "sonarr".to_string(), + namespace: Some("default".to_string()), + }, + ..Default::default() + }, + status: None, + }; + + apply_resource(&ctx.client, &config1).await.expect("Failed to create first config"); + + wait_for_ready::( + &ctx.client, + E2E_NAMESPACE, + &config1_name, + E2E_TIMEOUT, + ) + .await + .expect("First config never became ready"); + + tracing::info!("First config created successfully"); + + // Try to create second config for same instance + let config2_name = unique_name("e2e-mmc-2"); + ctx.register_cleanup("SonarrMediaManagementConfig", E2E_NAMESPACE, &config2_name); + + let config2 = SonarrMediaManagementConfig { + metadata: ObjectMeta { + name: Some(config2_name.clone()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrMediaManagementConfigSpec { + recycle_bin_cleanup_days: Some(14), + sonarr_instance_ref: SonarrInstanceRef { + name: "sonarr".to_string(), // Same instance + namespace: Some("default".to_string()), + }, + ..Default::default() + }, + status: None, + }; + + apply_resource(&ctx.client, &config2).await.expect("Failed to create second config CR"); + + // Wait a bit for operator to process + tokio::time::sleep(Duration::from_secs(10)).await; + + // Check that second config has a conflict/error condition + let api: Api = Api::namespaced(ctx.client.clone(), E2E_NAMESPACE); + let config2_status = api.get(&config2_name).await.expect("Failed to get second config"); + + if let Some(status) = &config2_status.status { + let has_conflict = status.conditions.iter().any(|c| { + (c.type_ == "Ready" && c.status == "False") || + c.type_ == "Conflict" + }); + + if has_conflict { + tracing::info!("✓ Singleton constraint enforced - second config has error/conflict condition"); + } else { + // The operator might handle this differently + tracing::warn!("Second config doesn't show conflict - singleton may not be enforced at controller level"); + } + } + + ctx.cleanup().await; +} diff --git a/tests/e2e/scenarios/cross_resource_dependencies.rs b/tests/e2e/scenarios/cross_resource_dependencies.rs new file mode 100644 index 0000000..d6067c8 --- /dev/null +++ b/tests/e2e/scenarios/cross_resource_dependencies.rs @@ -0,0 +1,502 @@ +//! E2E tests for cross-resource dependencies +//! +//! Tests scenarios where resources depend on each other: +//! - AutoTag depending on Tags +//! - QualityProfile depending on CustomFormats +//! - DelayProfile depending on Tags +//! - Notifications with Tags + +use crate::common::*; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::api::{Api, DeleteParams}; +use sonarr_operator::crds::delay_profile::DownloadProtocol; +use sonarr_operator::crds::{ + SonarrAutoTag, SonarrAutoTagSpec, SonarrCustomFormat, SonarrCustomFormatSpec, + SonarrDelayProfile, SonarrDelayProfileSpec, SonarrInstanceRef, SonarrTag, SonarrTagSpec, +}; +use std::time::Duration; + +/// Test AutoTag that references Tags +/// AutoTags assign tags to series based on conditions, so they need existing tags +#[tokio::test] +#[ignore = "requires E2E environment - run with: cargo test --test e2e -- --ignored"] +async fn test_autotag_with_tag_dependency() { + let mut ctx = TestContext::new() + .await + .expect("Failed to create test context"); + setup_e2e_namespace(&ctx.client) + .await + .expect("Failed to setup namespace"); + + // Step 1: Create the dependent tag first + let tag_name = unique_name("e2e-dep-tag"); + let tag_label = format!("dependency-{}", tag_name); + ctx.register_cleanup("SonarrTag", E2E_NAMESPACE, &tag_name); + + tracing::info!("Step 1: Creating dependency tag: {}", tag_name); + let tag = SonarrTag { + metadata: ObjectMeta { + name: Some(tag_name.clone()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrTagSpec { + label: tag_label.clone(), + sonarr_instance_ref: SonarrInstanceRef { + name: "sonarr".to_string(), + namespace: Some("default".to_string()), + }, + }, + status: None, + }; + + apply_resource(&ctx.client, &tag) + .await + .expect("Failed to create tag"); + + // Wait for tag to be ready and get its ID + let ready_tag = wait_for_ready::(&ctx.client, E2E_NAMESPACE, &tag_name, E2E_TIMEOUT) + .await + .expect("Tag never became ready"); + + let tag_id = ready_tag + .status + .as_ref() + .and_then(|s| s.id) + .expect("Tag should have ID"); + tracing::info!("Tag created with ID: {}", tag_id); + + // Verify tag exists in Sonarr + let sonarr_tag = ctx + .sonarr + .find_tag_by_label(&tag_label) + .await + .expect("Failed to query Sonarr") + .expect("Tag not found in Sonarr"); + assert_eq!(sonarr_tag.id, tag_id); + tracing::info!("✓ Dependency tag verified in Sonarr"); + + // Step 2: Create AutoTag that uses the tag + let autotag_name = unique_name("e2e-autotag"); + ctx.register_cleanup("SonarrAutoTag", E2E_NAMESPACE, &autotag_name); + + tracing::info!( + "Step 2: Creating AutoTag that references tag ID: {}", + tag_id + ); + let autotag = SonarrAutoTag { + metadata: ObjectMeta { + name: Some(autotag_name.clone()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrAutoTagSpec { + name: format!("E2E AutoTag {}", autotag_name), + remove_tags_automatically: false, + tags: vec![tag_id], // Reference the tag we created + specifications: vec![], + sonarr_instance_ref: SonarrInstanceRef { + name: "sonarr".to_string(), + namespace: Some("default".to_string()), + }, + }, + status: None, + }; + + apply_resource(&ctx.client, &autotag) + .await + .expect("Failed to create autotag"); + + // Wait for autotag to be ready + let ready_autotag = + wait_for_ready::(&ctx.client, E2E_NAMESPACE, &autotag_name, E2E_TIMEOUT) + .await + .expect("AutoTag never became ready"); + + let autotag_id = ready_autotag + .status + .as_ref() + .and_then(|s| s.id) + .expect("AutoTag should have ID"); + tracing::info!("AutoTag created with ID: {}", autotag_id); + + // Verify in Sonarr and check the tag reference + let sonarr_autotag = ctx + .sonarr + .find_auto_tag_by_name(&format!("E2E AutoTag {}", autotag_name)) + .await + .expect("Failed to query Sonarr") + .expect("AutoTag not found in Sonarr"); + + assert_eq!(sonarr_autotag.id, autotag_id); + assert!( + sonarr_autotag.tags.contains(&tag_id), + "AutoTag should reference tag ID {}. Actual tags: {:?}", + tag_id, + sonarr_autotag.tags + ); + tracing::info!("✓ AutoTag verified with correct tag reference"); + + // Step 3: Test cascade behavior - delete resources in correct order + // AutoTag first (dependent), then Tag (dependency) + tracing::info!("Step 3: Testing deletion order..."); + + let autotag_api: Api = Api::namespaced(ctx.client.clone(), E2E_NAMESPACE); + autotag_api + .delete(&autotag_name, &DeleteParams::default()) + .await + .expect("Failed to delete autotag"); + + wait_for_deletion::(&ctx.client, E2E_NAMESPACE, &autotag_name, QUICK_TIMEOUT) + .await + .expect("AutoTag not deleted"); + + tokio::time::sleep(Duration::from_secs(2)).await; + + // Now delete the tag + let tag_api: Api = Api::namespaced(ctx.client.clone(), E2E_NAMESPACE); + tag_api + .delete(&tag_name, &DeleteParams::default()) + .await + .expect("Failed to delete tag"); + + wait_for_deletion::(&ctx.client, E2E_NAMESPACE, &tag_name, QUICK_TIMEOUT) + .await + .expect("Tag not deleted"); + + tracing::info!("✓ Cross-resource dependency test completed successfully"); + ctx.cleanup().await; +} + +/// Test DelayProfile that references Tags +#[tokio::test] +#[ignore = "requires E2E environment - run with: cargo test --test e2e -- --ignored"] +async fn test_delay_profile_with_tag_dependency() { + let mut ctx = TestContext::new() + .await + .expect("Failed to create test context"); + setup_e2e_namespace(&ctx.client) + .await + .expect("Failed to setup namespace"); + + // Create dependency tag + let tag_name = unique_name("e2e-delay-tag"); + let tag_label = format!("delay-dep-{}", tag_name); + ctx.register_cleanup("SonarrTag", E2E_NAMESPACE, &tag_name); + + let tag = SonarrTag { + metadata: ObjectMeta { + name: Some(tag_name.clone()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrTagSpec { + label: tag_label.clone(), + sonarr_instance_ref: SonarrInstanceRef { + name: "sonarr".to_string(), + namespace: Some("default".to_string()), + }, + }, + status: None, + }; + + apply_resource(&ctx.client, &tag) + .await + .expect("Failed to create tag"); + let ready_tag = wait_for_ready::(&ctx.client, E2E_NAMESPACE, &tag_name, E2E_TIMEOUT) + .await + .expect("Tag never became ready"); + let tag_id = ready_tag + .status + .as_ref() + .and_then(|s| s.id) + .expect("Tag should have ID"); + tracing::info!("Dependency tag created with ID: {}", tag_id); + + // Create DelayProfile with tag + let dp_name = unique_name("e2e-delay"); + ctx.register_cleanup("SonarrDelayProfile", E2E_NAMESPACE, &dp_name); + + let delay_profile = SonarrDelayProfile { + metadata: ObjectMeta { + name: Some(dp_name.clone()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrDelayProfileSpec { + enable_usenet: true, + enable_torrent: true, + preferred_protocol: DownloadProtocol::Usenet, + usenet_delay: 60, + torrent_delay: 120, + tags: vec![tag_id], + bypass_if_highest_quality: false, + bypass_if_above_custom_format_score: false, + minimum_custom_format_score: 0, + order: 0, + sonarr_instance_ref: SonarrInstanceRef { + name: "sonarr".to_string(), + namespace: Some("default".to_string()), + }, + }, + status: None, + }; + + apply_resource(&ctx.client, &delay_profile) + .await + .expect("Failed to create delay profile"); + + let ready_dp = + wait_for_ready::(&ctx.client, E2E_NAMESPACE, &dp_name, E2E_TIMEOUT) + .await + .expect("DelayProfile never became ready"); + + let dp_id = ready_dp + .status + .as_ref() + .and_then(|s| s.id) + .expect("DelayProfile should have ID"); + tracing::info!("DelayProfile created with ID: {}", dp_id); + + // Verify delay profile has the tag + let delay_profiles = ctx + .sonarr + .get_delay_profiles() + .await + .expect("Failed to get delay profiles"); + let our_dp = delay_profiles.iter().find(|dp| dp.id == dp_id); + + assert!(our_dp.is_some(), "DelayProfile not found in Sonarr"); + let our_dp = our_dp.unwrap(); + assert!( + our_dp.tags.contains(&tag_id), + "DelayProfile should have tag {}. Actual: {:?}", + tag_id, + our_dp.tags + ); + tracing::info!("✓ DelayProfile verified with tag reference"); + + ctx.cleanup().await; +} + +/// Test that creating a resource with invalid tag reference fails gracefully +#[tokio::test] +#[ignore = "requires E2E environment - run with: cargo test --test e2e -- --ignored"] +async fn test_invalid_tag_reference_handling() { + let mut ctx = TestContext::new() + .await + .expect("Failed to create test context"); + setup_e2e_namespace(&ctx.client) + .await + .expect("Failed to setup namespace"); + + // Create AutoTag with a non-existent tag ID + let autotag_name = unique_name("e2e-invalid-ref"); + ctx.register_cleanup("SonarrAutoTag", E2E_NAMESPACE, &autotag_name); + + let autotag = SonarrAutoTag { + metadata: ObjectMeta { + name: Some(autotag_name.clone()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrAutoTagSpec { + name: format!("E2E Invalid Ref {}", autotag_name), + remove_tags_automatically: false, + tags: vec![99999], // Non-existent tag ID + specifications: vec![], + sonarr_instance_ref: SonarrInstanceRef { + name: "sonarr".to_string(), + namespace: Some("default".to_string()), + }, + }, + status: None, + }; + + apply_resource(&ctx.client, &autotag) + .await + .expect("Failed to create autotag CR"); + + // The resource should be created in K8s but may have an error condition + // Wait a bit for the operator to process it + tokio::time::sleep(Duration::from_secs(10)).await; + + // Check if the resource has an error condition or was created anyway + // (Sonarr API behavior varies - it might accept invalid tag IDs or reject them) + let api: Api = Api::namespaced(ctx.client.clone(), E2E_NAMESPACE); + let result = api.get(&autotag_name).await; + + if let Ok(autotag) = result { + if let Some(status) = &autotag.status { + let has_error = status + .conditions + .iter() + .any(|c| c.type_ == "Ready" && c.status == "False"); + + if has_error { + tracing::info!("✓ AutoTag correctly shows error for invalid tag reference"); + } else { + // Some Sonarr versions accept invalid tag IDs + tracing::warn!( + "AutoTag was created despite invalid tag reference - Sonarr may accept any tag ID" + ); + } + } + } + + ctx.cleanup().await; +} + +/// Test multiple resources created in dependency order +#[tokio::test] +#[ignore = "requires E2E environment - run with: cargo test --test e2e -- --ignored"] +async fn test_full_dependency_chain() { + let mut ctx = TestContext::new() + .await + .expect("Failed to create test context"); + setup_e2e_namespace(&ctx.client) + .await + .expect("Failed to setup namespace"); + + let suffix = unique_name("chain"); + + // Level 1: Create Tags (no dependencies) + let tag_names: Vec<_> = (1..=2) + .map(|i| format!("chain-tag-{}-{}", i, suffix)) + .collect(); + let mut tag_ids = Vec::new(); + + for tag_name in &tag_names { + ctx.register_cleanup("SonarrTag", E2E_NAMESPACE, tag_name); + let tag = SonarrTag { + metadata: ObjectMeta { + name: Some(tag_name.clone()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrTagSpec { + label: tag_name.clone(), + sonarr_instance_ref: SonarrInstanceRef { + name: "sonarr".to_string(), + namespace: Some("default".to_string()), + }, + }, + status: None, + }; + apply_resource(&ctx.client, &tag) + .await + .expect("Failed to create tag"); + } + + // Wait for all tags + for tag_name in &tag_names { + let ready = wait_for_ready::(&ctx.client, E2E_NAMESPACE, tag_name, E2E_TIMEOUT) + .await + .expect("Tag not ready"); + tag_ids.push(ready.status.as_ref().and_then(|s| s.id).unwrap()); + } + tracing::info!("Level 1: Created {} tags: {:?}", tag_names.len(), tag_ids); + + // Level 2: Create CustomFormat (no dependencies) + let cf_name = format!("chain-cf-{}", suffix); + ctx.register_cleanup("SonarrCustomFormat", E2E_NAMESPACE, &cf_name); + + let custom_format = SonarrCustomFormat { + metadata: ObjectMeta { + name: Some(cf_name.clone()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrCustomFormatSpec { + name: cf_name.clone(), + include_custom_format_when_renaming: false, + specifications: vec![], + sonarr_instance_ref: SonarrInstanceRef { + name: "sonarr".to_string(), + namespace: Some("default".to_string()), + }, + }, + status: None, + }; + apply_resource(&ctx.client, &custom_format) + .await + .expect("Failed to create custom format"); + + let ready_cf = + wait_for_ready::(&ctx.client, E2E_NAMESPACE, &cf_name, E2E_TIMEOUT) + .await + .expect("CustomFormat not ready"); + let cf_id = ready_cf.status.as_ref().and_then(|s| s.id).unwrap(); + tracing::info!("Level 2: Created CustomFormat with ID: {}", cf_id); + + // Level 3: Create DelayProfile (depends on Tags) + let dp_name = format!("chain-dp-{}", suffix); + ctx.register_cleanup("SonarrDelayProfile", E2E_NAMESPACE, &dp_name); + + let delay_profile = SonarrDelayProfile { + metadata: ObjectMeta { + name: Some(dp_name.clone()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrDelayProfileSpec { + enable_usenet: true, + enable_torrent: false, + preferred_protocol: DownloadProtocol::Usenet, + usenet_delay: 30, + torrent_delay: 0, + tags: tag_ids.clone(), + bypass_if_highest_quality: false, + bypass_if_above_custom_format_score: false, + minimum_custom_format_score: 0, + order: 0, + sonarr_instance_ref: SonarrInstanceRef { + name: "sonarr".to_string(), + namespace: Some("default".to_string()), + }, + }, + status: None, + }; + apply_resource(&ctx.client, &delay_profile) + .await + .expect("Failed to create delay profile"); + + let ready_dp = + wait_for_ready::(&ctx.client, E2E_NAMESPACE, &dp_name, E2E_TIMEOUT) + .await + .expect("DelayProfile not ready"); + let dp_id = ready_dp.status.as_ref().and_then(|s| s.id).unwrap(); + tracing::info!( + "Level 3: Created DelayProfile with ID: {} referencing tags: {:?}", + dp_id, + tag_ids + ); + + // Verify the full chain in Sonarr + let sonarr_tags = ctx.sonarr.get_tags().await.expect("Failed to get tags"); + for tag_id in &tag_ids { + assert!( + sonarr_tags.iter().any(|t| t.id == *tag_id), + "Tag {} not found in Sonarr", + tag_id + ); + } + + let sonarr_cf = ctx + .sonarr + .find_custom_format_by_name(&cf_name) + .await + .expect("Failed to get CF"); + assert!(sonarr_cf.is_some(), "CustomFormat not found in Sonarr"); + + let delay_profiles = ctx + .sonarr + .get_delay_profiles() + .await + .expect("Failed to get delay profiles"); + let our_dp = delay_profiles.iter().find(|dp| dp.id == dp_id); + assert!(our_dp.is_some(), "DelayProfile not found in Sonarr"); + + tracing::info!("✓ Full dependency chain verified successfully"); + ctx.cleanup().await; +} diff --git a/tests/e2e/scenarios/custom_format_lifecycle.rs b/tests/e2e/scenarios/custom_format_lifecycle.rs new file mode 100644 index 0000000..da649f3 --- /dev/null +++ b/tests/e2e/scenarios/custom_format_lifecycle.rs @@ -0,0 +1,93 @@ +//! E2E tests for CustomFormat lifecycle + +use crate::common::*; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::api::{Api, DeleteParams}; +use sonarr_operator::crds::{SonarrCustomFormat, SonarrCustomFormatSpec, SonarrInstanceRef}; +use std::time::Duration; + +/// Test the full lifecycle of a CustomFormat +#[tokio::test] +#[ignore = "requires E2E environment - run with: cargo test --test e2e -- --ignored"] +async fn test_custom_format_full_lifecycle() { + let mut ctx = TestContext::new().await.expect("Failed to create test context"); + setup_e2e_namespace(&ctx.client).await.expect("Failed to setup namespace"); + + let cf_name = unique_name("e2e-cf"); + let format_name = format!("E2E Custom Format {}", cf_name); + + ctx.register_cleanup("SonarrCustomFormat", E2E_NAMESPACE, &cf_name); + + // Create the custom format CR + tracing::info!("Creating SonarrCustomFormat: {}", cf_name); + let custom_format = SonarrCustomFormat { + metadata: ObjectMeta { + name: Some(cf_name.clone()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrCustomFormatSpec { + name: format_name.clone(), + include_custom_format_when_renaming: false, + specifications: vec![], // Basic format without specifications + sonarr_instance_ref: SonarrInstanceRef { + name: "sonarr".to_string(), + namespace: Some("default".to_string()), + }, + }, + status: None, + }; + + apply_resource(&ctx.client, &custom_format).await.expect("Failed to create custom format CR"); + + // Wait for Ready condition + tracing::info!("Waiting for custom format to be ready..."); + let ready_cf = wait_for_ready::( + &ctx.client, + E2E_NAMESPACE, + &cf_name, + E2E_TIMEOUT, + ) + .await + .expect("Custom format never became ready"); + + let cf_id = ready_cf + .status + .as_ref() + .and_then(|s| s.id) + .expect("Custom format should have an ID"); + tracing::info!("Custom format created with ID: {}", cf_id); + + // Verify in Sonarr API + tracing::info!("Verifying custom format exists in Sonarr..."); + let sonarr_cf = ctx.sonarr + .find_custom_format_by_name(&format_name) + .await + .expect("Failed to query Sonarr API") + .expect("Custom format not found in Sonarr"); + + assert_eq!(sonarr_cf.id, cf_id, "Custom format ID mismatch"); + assert_eq!(sonarr_cf.name, format_name, "Custom format name mismatch"); + tracing::info!("✓ Custom format verified in Sonarr"); + + // Delete and verify cleanup + let api: Api = Api::namespaced(ctx.client.clone(), E2E_NAMESPACE); + api.delete(&cf_name, &DeleteParams::default()) + .await + .expect("Failed to delete custom format CR"); + + wait_for_deletion::(&ctx.client, E2E_NAMESPACE, &cf_name, QUICK_TIMEOUT) + .await + .expect("Custom format CR was not deleted"); + + tokio::time::sleep(Duration::from_secs(3)).await; + + let deleted = ctx.sonarr.find_custom_format_by_name(&format_name).await; + assert!( + matches!(deleted, Ok(None)), + "Custom format should be deleted from Sonarr" + ); + tracing::info!("✓ Custom format deleted from Sonarr"); + + ctx.cleanup().await; +} diff --git a/tests/e2e/scenarios/error_recovery.rs b/tests/e2e/scenarios/error_recovery.rs new file mode 100644 index 0000000..e089d59 --- /dev/null +++ b/tests/e2e/scenarios/error_recovery.rs @@ -0,0 +1,318 @@ +//! E2E tests for error recovery scenarios +//! +//! Tests how the operator handles error conditions and recovers from them. + +use crate::common::*; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::api::{Api, Patch, PatchParams}; +use sonarr_operator::crds::*; +use std::time::Duration; + +/// Test that operator recovers when Sonarr becomes available +#[tokio::test] +#[ignore = "requires E2E environment - run with: cargo test --test e2e -- --ignored"] +async fn test_recovery_after_sonarr_available() { + let mut ctx = TestContext::new() + .await + .expect("Failed to create test context"); + setup_e2e_namespace(&ctx.client) + .await + .expect("Failed to setup namespace"); + + // Ensure Sonarr is available + ctx.sonarr + .wait_for_ready(Duration::from_secs(60)) + .await + .expect("Sonarr should be ready"); + + // Create a tag + let tag_name = unique_name("e2e-recovery"); + ctx.register_cleanup("SonarrTag", E2E_NAMESPACE, &tag_name); + + let tag = SonarrTag { + metadata: ObjectMeta { + name: Some(tag_name.clone()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrTagSpec { + label: tag_name.clone(), + sonarr_instance_ref: SonarrInstanceRef { + name: "sonarr".to_string(), + namespace: Some("default".to_string()), + }, + }, + status: None, + }; + + apply_resource(&ctx.client, &tag) + .await + .expect("Failed to create tag"); + + // Wait for ready + wait_for_ready::(&ctx.client, E2E_NAMESPACE, &tag_name, E2E_TIMEOUT) + .await + .expect("Tag never became ready"); + + // Verify in Sonarr + let sonarr_tag = ctx + .sonarr + .find_tag_by_label(&tag_name) + .await + .expect("Failed to query Sonarr") + .expect("Tag not found"); + + tracing::info!("✓ Tag created successfully with ID: {}", sonarr_tag.id); + ctx.cleanup().await; +} + +/// Test handling of invalid Sonarr instance reference +#[tokio::test] +#[ignore = "requires E2E environment - run with: cargo test --test e2e -- --ignored"] +async fn test_invalid_sonarr_instance_reference() { + let mut ctx = TestContext::new() + .await + .expect("Failed to create test context"); + setup_e2e_namespace(&ctx.client) + .await + .expect("Failed to setup namespace"); + + let tag_name = unique_name("e2e-invalid-instance"); + ctx.register_cleanup("SonarrTag", E2E_NAMESPACE, &tag_name); + + // Create tag with non-existent Sonarr instance + let tag = SonarrTag { + metadata: ObjectMeta { + name: Some(tag_name.clone()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrTagSpec { + label: tag_name.clone(), + sonarr_instance_ref: SonarrInstanceRef { + name: "nonexistent-sonarr".to_string(), + namespace: Some("default".to_string()), + }, + }, + status: None, + }; + + apply_resource(&ctx.client, &tag) + .await + .expect("Failed to create tag CR"); + + // Wait for operator to process + tokio::time::sleep(Duration::from_secs(15)).await; + + // Check status - should have error condition + let api: Api = Api::namespaced(ctx.client.clone(), E2E_NAMESPACE); + let tag_status = api.get(&tag_name).await.expect("Failed to get tag"); + + if let Some(status) = &tag_status.status { + let has_error = status + .conditions + .iter() + .any(|c| c.type_ == "Ready" && c.status == "False"); + + if has_error { + let error_msg = status + .conditions + .iter() + .find(|c| c.type_ == "Ready" && c.status == "False") + .map(|c| c.message.clone()); + tracing::info!( + "✓ Tag correctly shows error for invalid instance: {:?}", + error_msg + ); + } else { + tracing::warn!("Tag doesn't show error for invalid instance reference"); + } + } + + ctx.cleanup().await; +} + +/// Test that updating a resource triggers reconciliation +#[tokio::test] +#[ignore = "requires E2E environment - run with: cargo test --test e2e -- --ignored"] +async fn test_resource_update_triggers_reconciliation() { + let mut ctx = TestContext::new() + .await + .expect("Failed to create test context"); + setup_e2e_namespace(&ctx.client) + .await + .expect("Failed to setup namespace"); + + let tag_name = unique_name("e2e-update-reconcile"); + ctx.register_cleanup("SonarrTag", E2E_NAMESPACE, &tag_name); + + // Create tag + let tag = SonarrTag { + metadata: ObjectMeta { + name: Some(tag_name.clone()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrTagSpec { + label: format!("{}-v1", tag_name), + sonarr_instance_ref: SonarrInstanceRef { + name: "sonarr".to_string(), + namespace: Some("default".to_string()), + }, + }, + status: None, + }; + + apply_resource(&ctx.client, &tag) + .await + .expect("Failed to create tag"); + wait_for_ready::(&ctx.client, E2E_NAMESPACE, &tag_name, E2E_TIMEOUT) + .await + .expect("Tag v1 never became ready"); + + // Verify v1 in Sonarr + let v1_label = format!("{}-v1", tag_name); + ctx.sonarr + .find_tag_by_label(&v1_label) + .await + .expect("Failed to query") + .expect("Tag v1 not found in Sonarr"); + tracing::info!("✓ Tag v1 created in Sonarr"); + + // Update to v2 + let api: Api = Api::namespaced(ctx.client.clone(), E2E_NAMESPACE); + let v2_label = format!("{}-v2", tag_name); + let patch = serde_json::json!({ + "apiVersion": "devopsarr.io/v1alpha1", + "kind": "SonarrTag", + "metadata": { + "name": tag_name, + "namespace": E2E_NAMESPACE + }, + "spec": { + "label": v2_label, + "sonarrInstanceRef": { + "name": "sonarr", + "namespace": "default" + } + } + }); + + api.patch( + &tag_name, + &PatchParams::apply("sonarr-e2e-test").force(), + &Patch::Apply(&patch), + ) + .await + .expect("Failed to update tag"); + + // Wait for update to propagate + tokio::time::sleep(Duration::from_secs(5)).await; + + // Verify v2 in Sonarr (v1 label should no longer exist) + let v2_in_sonarr = ctx + .sonarr + .find_tag_by_label(&v2_label) + .await + .expect("Failed to query"); + + assert!(v2_in_sonarr.is_some(), "Tag v2 should exist in Sonarr"); + + let v1_in_sonarr = ctx + .sonarr + .find_tag_by_label(&v1_label) + .await + .expect("Failed to query"); + + assert!( + v1_in_sonarr.is_none(), + "Tag v1 label should not exist after update" + ); + + tracing::info!("✓ Tag update correctly reconciled - v1 -> v2"); + ctx.cleanup().await; +} + +/// Test rapid updates don't cause issues +#[tokio::test] +#[ignore = "requires E2E environment - run with: cargo test --test e2e -- --ignored"] +async fn test_rapid_updates_handling() { + let mut ctx = TestContext::new() + .await + .expect("Failed to create test context"); + setup_e2e_namespace(&ctx.client) + .await + .expect("Failed to setup namespace"); + + let tag_name = unique_name("e2e-rapid"); + ctx.register_cleanup("SonarrTag", E2E_NAMESPACE, &tag_name); + + let api: Api = Api::namespaced(ctx.client.clone(), E2E_NAMESPACE); + + // Create initial tag + let tag = SonarrTag { + metadata: ObjectMeta { + name: Some(tag_name.clone()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrTagSpec { + label: format!("{}-initial", tag_name), + sonarr_instance_ref: SonarrInstanceRef { + name: "sonarr".to_string(), + namespace: Some("default".to_string()), + }, + }, + status: None, + }; + + apply_resource(&ctx.client, &tag) + .await + .expect("Failed to create tag"); + + // Rapid fire updates + for i in 1..=5 { + let patch = serde_json::json!({ + "apiVersion": "devopsarr.io/v1alpha1", + "kind": "SonarrTag", + "metadata": { + "name": tag_name, + "namespace": E2E_NAMESPACE + }, + "spec": { + "label": format!("{}-update-{}", tag_name, i), + "sonarrInstanceRef": { + "name": "sonarr", + "namespace": "default" + } + } + }); + + api.patch( + &tag_name, + &PatchParams::apply("sonarr-e2e-test").force(), + &Patch::Apply(&patch), + ) + .await + .expect("Failed to update tag"); + + // Small delay between updates + tokio::time::sleep(Duration::from_millis(100)).await; + } + + // Wait for final reconciliation + tokio::time::sleep(Duration::from_secs(10)).await; + + // Verify final state + let final_label = format!("{}-update-5", tag_name); + let sonarr_tag = ctx + .sonarr + .find_tag_by_label(&final_label) + .await + .expect("Failed to query"); + + assert!(sonarr_tag.is_some(), "Final tag state should be in Sonarr"); + tracing::info!("✓ Rapid updates handled correctly"); + + ctx.cleanup().await; +} diff --git a/tests/e2e/scenarios/mod.rs b/tests/e2e/scenarios/mod.rs new file mode 100644 index 0000000..9c9c64b --- /dev/null +++ b/tests/e2e/scenarios/mod.rs @@ -0,0 +1,11 @@ +//! E2E test scenarios +//! +//! Each module tests a specific resource type or cross-resource dependency. + +pub mod tag_lifecycle; +pub mod root_folder_lifecycle; +pub mod quality_profile_lifecycle; +pub mod custom_format_lifecycle; +pub mod cross_resource_dependencies; +pub mod config_resources; +pub mod error_recovery; diff --git a/tests/e2e/scenarios/quality_profile_lifecycle.rs b/tests/e2e/scenarios/quality_profile_lifecycle.rs new file mode 100644 index 0000000..d05af9d --- /dev/null +++ b/tests/e2e/scenarios/quality_profile_lifecycle.rs @@ -0,0 +1,160 @@ +//! E2E tests for QualityProfile lifecycle + +use crate::common::*; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::api::{Api, DeleteParams, Patch, PatchParams}; +use sonarr_operator::crds::{SonarrInstanceRef, SonarrQualityProfile, SonarrQualityProfileSpec}; +use sonarr_operator::crds::quality_profile::{QualityGroup, Quality}; +use std::time::Duration; + +/// Test the full lifecycle of a QualityProfile +#[tokio::test] +#[ignore = "requires E2E environment - run with: cargo test --test e2e -- --ignored"] +async fn test_quality_profile_full_lifecycle() { + let mut ctx = TestContext::new().await.expect("Failed to create test context"); + setup_e2e_namespace(&ctx.client).await.expect("Failed to setup namespace"); + + let qp_name = unique_name("e2e-qp"); + let profile_name = format!("E2E Test Profile {}", qp_name); + + ctx.register_cleanup("SonarrQualityProfile", E2E_NAMESPACE, &qp_name); + + // Create the quality profile CR with minimal required fields + tracing::info!("Creating SonarrQualityProfile: {}", qp_name); + let quality_profile = SonarrQualityProfile { + metadata: ObjectMeta { + name: Some(qp_name.clone()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrQualityProfileSpec { + name: profile_name.clone(), + upgrade_allowed: true, + cutoff: 7, // Bluray-1080p + quality_groups: vec![ + QualityGroup { + id: None, + name: Some("HD".to_string()), + qualities: vec![ + Quality { + id: Some(7), + name: Some("Bluray-1080p".to_string()), + source: None, + resolution: None, + }, + ], + }, + ], + sonarr_instance_ref: SonarrInstanceRef { + name: "sonarr".to_string(), + namespace: Some("default".to_string()), + }, + cutoff_format_score: None, + min_format_score: None, + min_upgrade_format_score: None, + format_items: vec![], + }, + status: None, + }; + + apply_resource(&ctx.client, &quality_profile).await.expect("Failed to create quality profile CR"); + + // Wait for Ready condition + tracing::info!("Waiting for quality profile to be ready..."); + let ready_qp = wait_for_ready::( + &ctx.client, + E2E_NAMESPACE, + &qp_name, + E2E_TIMEOUT, + ) + .await + .expect("Quality profile never became ready"); + + let qp_id = ready_qp + .status + .as_ref() + .and_then(|s| s.id) + .expect("Quality profile should have an ID"); + tracing::info!("Quality profile created with ID: {}", qp_id); + + // Verify in Sonarr API + tracing::info!("Verifying quality profile exists in Sonarr..."); + let sonarr_qp = ctx.sonarr + .find_quality_profile_by_name(&profile_name) + .await + .expect("Failed to query Sonarr API") + .expect("Quality profile not found in Sonarr"); + + assert_eq!(sonarr_qp.id, qp_id, "Quality profile ID mismatch"); + assert_eq!(sonarr_qp.name, profile_name, "Quality profile name mismatch"); + assert!(sonarr_qp.upgrade_allowed, "Upgrade should be allowed"); + tracing::info!("✓ Quality profile verified in Sonarr"); + + // Update the quality profile + let updated_profile_name = format!("{} Updated", profile_name); + tracing::info!("Updating quality profile name to: {}", updated_profile_name); + + let api: Api = Api::namespaced(ctx.client.clone(), E2E_NAMESPACE); + let patch = serde_json::json!({ + "apiVersion": "devopsarr.io/v1alpha1", + "kind": "SonarrQualityProfile", + "metadata": { + "name": qp_name, + "namespace": E2E_NAMESPACE + }, + "spec": { + "name": updated_profile_name, + "upgradeAllowed": false, + "cutoff": 7, + "qualityGroups": [{ + "name": "HD", + "qualities": [{"id": 7, "name": "Bluray-1080p"}] + }], + "sonarrInstanceRef": { + "name": "sonarr", + "namespace": "default" + } + } + }); + + api.patch( + &qp_name, + &PatchParams::apply("sonarr-e2e-test").force(), + &Patch::Apply(&patch), + ) + .await + .expect("Failed to update quality profile"); + + tokio::time::sleep(Duration::from_secs(5)).await; + + // Verify update in Sonarr + let updated_sonarr_qp = ctx.sonarr + .find_quality_profile_by_name(&updated_profile_name) + .await + .expect("Failed to query Sonarr API") + .expect("Updated quality profile not found in Sonarr"); + + assert_eq!(updated_sonarr_qp.id, qp_id, "ID should not change"); + assert!(!updated_sonarr_qp.upgrade_allowed, "Upgrade should be disabled"); + tracing::info!("✓ Quality profile update verified in Sonarr"); + + // Delete and verify cleanup + api.delete(&qp_name, &DeleteParams::default()) + .await + .expect("Failed to delete quality profile CR"); + + wait_for_deletion::(&ctx.client, E2E_NAMESPACE, &qp_name, QUICK_TIMEOUT) + .await + .expect("Quality profile CR was not deleted"); + + tokio::time::sleep(Duration::from_secs(3)).await; + + let deleted = ctx.sonarr.find_quality_profile_by_name(&updated_profile_name).await; + assert!( + matches!(deleted, Ok(None)), + "Quality profile should be deleted from Sonarr" + ); + tracing::info!("✓ Quality profile deleted from Sonarr"); + + ctx.cleanup().await; +} diff --git a/tests/e2e/scenarios/root_folder_lifecycle.rs b/tests/e2e/scenarios/root_folder_lifecycle.rs new file mode 100644 index 0000000..deb0f8d --- /dev/null +++ b/tests/e2e/scenarios/root_folder_lifecycle.rs @@ -0,0 +1,92 @@ +//! E2E tests for RootFolder lifecycle + +use crate::common::*; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::api::{Api, DeleteParams}; +use sonarr_operator::crds::{SonarrInstanceRef, SonarrRootFolder, SonarrRootFolderSpec}; +use std::time::Duration; + +/// Test the full lifecycle of a RootFolder +#[tokio::test] +#[ignore = "requires E2E environment - run with: cargo test --test e2e -- --ignored"] +async fn test_root_folder_full_lifecycle() { + let mut ctx = TestContext::new().await.expect("Failed to create test context"); + setup_e2e_namespace(&ctx.client).await.expect("Failed to setup namespace"); + + let rf_name = unique_name("e2e-rf"); + // Use a path that might exist in the container + let rf_path = format!("/data/e2e-test-{}", rf_name); + + ctx.register_cleanup("SonarrRootFolder", E2E_NAMESPACE, &rf_name); + + // Create the root folder CR + tracing::info!("Creating SonarrRootFolder: {} with path: {}", rf_name, rf_path); + let root_folder = SonarrRootFolder { + metadata: ObjectMeta { + name: Some(rf_name.clone()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrRootFolderSpec { + path: rf_path.clone(), + sonarr_instance_ref: SonarrInstanceRef { + name: "sonarr".to_string(), + namespace: Some("default".to_string()), + }, + }, + status: None, + }; + + apply_resource(&ctx.client, &root_folder).await.expect("Failed to create root folder CR"); + + // Wait for Ready condition + tracing::info!("Waiting for root folder to be ready..."); + let ready_rf = wait_for_ready::( + &ctx.client, + E2E_NAMESPACE, + &rf_name, + E2E_TIMEOUT, + ) + .await + .expect("Root folder never became ready"); + + let rf_id = ready_rf + .status + .as_ref() + .and_then(|s| s.id) + .expect("Root folder should have an ID"); + tracing::info!("Root folder created with ID: {}", rf_id); + + // Verify in Sonarr API + tracing::info!("Verifying root folder exists in Sonarr..."); + let sonarr_rf = ctx.sonarr + .find_root_folder_by_path(&rf_path) + .await + .expect("Failed to query Sonarr API") + .expect("Root folder not found in Sonarr"); + + assert_eq!(sonarr_rf.id, rf_id, "Root folder ID mismatch"); + assert_eq!(sonarr_rf.path, rf_path, "Root folder path mismatch"); + tracing::info!("✓ Root folder verified in Sonarr"); + + // Delete and verify cleanup + let api: Api = Api::namespaced(ctx.client.clone(), E2E_NAMESPACE); + api.delete(&rf_name, &DeleteParams::default()) + .await + .expect("Failed to delete root folder CR"); + + wait_for_deletion::(&ctx.client, E2E_NAMESPACE, &rf_name, QUICK_TIMEOUT) + .await + .expect("Root folder CR was not deleted"); + + tokio::time::sleep(Duration::from_secs(3)).await; + + let deleted = ctx.sonarr.find_root_folder_by_path(&rf_path).await; + assert!( + matches!(deleted, Ok(None)), + "Root folder should be deleted from Sonarr" + ); + tracing::info!("✓ Root folder deleted from Sonarr"); + + ctx.cleanup().await; +} diff --git a/tests/e2e/scenarios/tag_lifecycle.rs b/tests/e2e/scenarios/tag_lifecycle.rs new file mode 100644 index 0000000..927f87f --- /dev/null +++ b/tests/e2e/scenarios/tag_lifecycle.rs @@ -0,0 +1,222 @@ +//! E2E tests for Tag lifecycle +//! +//! Tests the full lifecycle of creating, updating, and deleting tags +//! with verification against the Sonarr API. + +use crate::common::*; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::api::{Api, DeleteParams, Patch, PatchParams}; +use sonarr_operator::crds::{SonarrInstanceRef, SonarrTag, SonarrTagSpec}; +use std::time::Duration; + +/// Test the full lifecycle of a Tag: create -> verify in Sonarr -> update -> verify -> delete -> verify deleted +#[tokio::test] +#[ignore = "requires E2E environment - run with: cargo test --test e2e -- --ignored"] +async fn test_tag_full_lifecycle() { + let mut ctx = TestContext::new() + .await + .expect("Failed to create test context"); + + // Setup namespace + setup_e2e_namespace(&ctx.client) + .await + .expect("Failed to setup namespace"); + + let tag_name = unique_name("e2e-tag"); + let tag_label = format!("e2e-test-{}", &tag_name); + + // Register for cleanup + ctx.register_cleanup("SonarrTag", E2E_NAMESPACE, &tag_name); + + // Step 1: Create the tag CR + tracing::info!("Creating SonarrTag: {}", tag_name); + let tag = SonarrTag { + metadata: ObjectMeta { + name: Some(tag_name.clone()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrTagSpec { + label: tag_label.clone(), + sonarr_instance_ref: SonarrInstanceRef { + name: "sonarr".to_string(), + namespace: Some("default".to_string()), + }, + }, + status: None, + }; + + apply_resource(&ctx.client, &tag) + .await + .expect("Failed to create tag CR"); + + // Step 2: Wait for Ready condition + tracing::info!("Waiting for tag to be ready..."); + let ready_tag = wait_for_ready::(&ctx.client, E2E_NAMESPACE, &tag_name, E2E_TIMEOUT) + .await + .expect("Tag never became ready"); + + // Verify the tag has an ID assigned + let tag_id = ready_tag + .status + .as_ref() + .and_then(|s| s.id) + .expect("Tag should have an ID after reconciliation"); + tracing::info!("Tag created with ID: {}", tag_id); + + // Step 3: Verify in Sonarr API + tracing::info!("Verifying tag exists in Sonarr..."); + let sonarr_tag = ctx + .sonarr + .find_tag_by_label(&tag_label) + .await + .expect("Failed to query Sonarr API") + .expect("Tag not found in Sonarr"); + + assert_eq!(sonarr_tag.id, tag_id, "Tag ID mismatch"); + assert_eq!(sonarr_tag.label, tag_label, "Tag label mismatch"); + tracing::info!("✓ Tag verified in Sonarr"); + + // Step 4: Update the tag + let updated_label = format!("{}-updated", tag_label); + tracing::info!("Updating tag label to: {}", updated_label); + + let api: Api = Api::namespaced(ctx.client.clone(), E2E_NAMESPACE); + let patch = serde_json::json!({ + "apiVersion": "devopsarr.io/v1alpha1", + "kind": "SonarrTag", + "metadata": { + "name": tag_name, + "namespace": E2E_NAMESPACE + }, + "spec": { + "label": updated_label, + "sonarrInstanceRef": { + "name": "sonarr", + "namespace": "default" + } + } + }); + + api.patch( + &tag_name, + &PatchParams::apply("sonarr-e2e-test").force(), + &Patch::Apply(&patch), + ) + .await + .expect("Failed to update tag"); + + // Wait for update to propagate + tokio::time::sleep(Duration::from_secs(5)).await; + + // Step 5: Verify update in Sonarr + tracing::info!("Verifying tag update in Sonarr..."); + let updated_sonarr_tag = ctx + .sonarr + .find_tag_by_label(&updated_label) + .await + .expect("Failed to query Sonarr API") + .expect("Updated tag not found in Sonarr"); + + assert_eq!( + updated_sonarr_tag.id, tag_id, + "Tag ID should not change on update" + ); + assert_eq!( + updated_sonarr_tag.label, updated_label, + "Tag label should be updated" + ); + tracing::info!("✓ Tag update verified in Sonarr"); + + // Step 6: Delete the tag CR + tracing::info!("Deleting SonarrTag: {}", tag_name); + api.delete(&tag_name, &DeleteParams::default()) + .await + .expect("Failed to delete tag CR"); + + // Step 7: Wait for deletion + wait_for_deletion::(&ctx.client, E2E_NAMESPACE, &tag_name, QUICK_TIMEOUT) + .await + .expect("Tag CR was not deleted"); + + // Step 8: Verify deleted from Sonarr (give operator time to process finalizer) + tokio::time::sleep(Duration::from_secs(3)).await; + + tracing::info!("Verifying tag deleted from Sonarr..."); + let deleted_tag = ctx.sonarr.find_tag_by_label(&updated_label).await; + + match deleted_tag { + Ok(None) => tracing::info!("✓ Tag successfully deleted from Sonarr"), + Ok(Some(_)) => panic!("Tag still exists in Sonarr after CR deletion"), + Err(e) => tracing::warn!("Could not verify deletion: {:?}", e), + } + + // Cleanup is handled by TestContext + ctx.cleanup().await; +} + +/// Test creating multiple tags +#[tokio::test] +#[ignore = "requires E2E environment - run with: cargo test --test e2e -- --ignored"] +async fn test_multiple_tags() { + let mut ctx = TestContext::new() + .await + .expect("Failed to create test context"); + setup_e2e_namespace(&ctx.client) + .await + .expect("Failed to setup namespace"); + + let tag_names: Vec = (1..=3) + .map(|i| unique_name(&format!("e2e-multi-{}", i))) + .collect(); + + // Create multiple tags + for tag_name in &tag_names { + ctx.register_cleanup("SonarrTag", E2E_NAMESPACE, tag_name); + + let tag = SonarrTag { + metadata: ObjectMeta { + name: Some(tag_name.clone()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrTagSpec { + label: tag_name.clone(), + sonarr_instance_ref: SonarrInstanceRef { + name: "sonarr".to_string(), + namespace: Some("default".to_string()), + }, + }, + status: None, + }; + + apply_resource(&ctx.client, &tag) + .await + .expect("Failed to create tag"); + } + + // Wait for all to be ready + for tag_name in &tag_names { + wait_for_ready::(&ctx.client, E2E_NAMESPACE, tag_name, E2E_TIMEOUT) + .await + .expect(&format!("Tag {} never became ready", tag_name)); + } + + // Verify all exist in Sonarr + for tag_name in &tag_names { + let sonarr_tag = ctx + .sonarr + .find_tag_by_label(tag_name) + .await + .expect("Failed to query Sonarr") + .expect(&format!("Tag {} not found in Sonarr", tag_name)); + + tracing::info!( + "✓ Tag {} created in Sonarr with ID {}", + tag_name, + sonarr_tag.id + ); + } + + ctx.cleanup().await; +} diff --git a/tests/e2e/sonarr_client.rs b/tests/e2e/sonarr_client.rs new file mode 100644 index 0000000..c11a3ae --- /dev/null +++ b/tests/e2e/sonarr_client.rs @@ -0,0 +1,480 @@ +//! Sonarr API client for E2E test verification +//! +//! This client directly queries the Sonarr API to verify that resources +//! created by the operator actually exist in Sonarr. + +use anyhow::{anyhow, Result}; +use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE}; +use serde::{Deserialize, Serialize}; +use std::time::Duration; + +/// Client for querying Sonarr API directly during E2E tests +pub struct SonarrTestClient { + client: reqwest::Client, + base_url: String, +} + +impl SonarrTestClient { + /// Create a new Sonarr test client + pub fn new(base_url: &str, api_key: &str) -> Result { + let mut headers = HeaderMap::new(); + headers.insert("X-Api-Key", HeaderValue::from_str(api_key)?); + headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + + let client = reqwest::Client::builder() + .default_headers(headers) + .timeout(Duration::from_secs(30)) + .build()?; + + Ok(Self { + client, + base_url: base_url.trim_end_matches('/').to_string(), + }) + } + + /// Check if Sonarr is healthy + pub async fn health_check(&self) -> Result { + let resp = self + .client + .get(format!("{}/api/v3/system/status", self.base_url)) + .send() + .await?; + Ok(resp.status().is_success()) + } + + /// Wait for Sonarr to be ready + pub async fn wait_for_ready(&self, timeout: Duration) -> Result<()> { + let start = std::time::Instant::now(); + while start.elapsed() < timeout { + if self.health_check().await.unwrap_or(false) { + return Ok(()); + } + tokio::time::sleep(Duration::from_secs(2)).await; + } + Err(anyhow!("Timeout waiting for Sonarr to be ready")) + } + + // ========== Tags ========== + + /// Get all tags from Sonarr + pub async fn get_tags(&self) -> Result> { + let resp = self + .client + .get(format!("{}/api/v3/tag", self.base_url)) + .send() + .await? + .error_for_status()?; + Ok(resp.json().await?) + } + + /// Find a tag by label + pub async fn find_tag_by_label(&self, label: &str) -> Result> { + let tags = self.get_tags().await?; + Ok(tags.into_iter().find(|t| t.label == label)) + } + + /// Delete a tag by ID + pub async fn delete_tag(&self, id: i32) -> Result<()> { + self.client + .delete(format!("{}/api/v3/tag/{}", self.base_url, id)) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + // ========== Root Folders ========== + + /// Get all root folders from Sonarr + pub async fn get_root_folders(&self) -> Result> { + let resp = self + .client + .get(format!("{}/api/v3/rootfolder", self.base_url)) + .send() + .await? + .error_for_status()?; + Ok(resp.json().await?) + } + + /// Find a root folder by path + pub async fn find_root_folder_by_path(&self, path: &str) -> Result> { + let folders = self.get_root_folders().await?; + Ok(folders.into_iter().find(|f| f.path == path)) + } + + /// Delete a root folder by ID + pub async fn delete_root_folder(&self, id: i32) -> Result<()> { + self.client + .delete(format!("{}/api/v3/rootfolder/{}", self.base_url, id)) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + // ========== Quality Profiles ========== + + /// Get all quality profiles from Sonarr + pub async fn get_quality_profiles(&self) -> Result> { + let resp = self + .client + .get(format!("{}/api/v3/qualityprofile", self.base_url)) + .send() + .await? + .error_for_status()?; + Ok(resp.json().await?) + } + + /// Find a quality profile by name + pub async fn find_quality_profile_by_name(&self, name: &str) -> Result> { + let profiles = self.get_quality_profiles().await?; + Ok(profiles.into_iter().find(|p| p.name == name)) + } + + /// Delete a quality profile by ID + pub async fn delete_quality_profile(&self, id: i32) -> Result<()> { + self.client + .delete(format!("{}/api/v3/qualityprofile/{}", self.base_url, id)) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + // ========== Custom Formats ========== + + /// Get all custom formats from Sonarr + pub async fn get_custom_formats(&self) -> Result> { + let resp = self + .client + .get(format!("{}/api/v3/customformat", self.base_url)) + .send() + .await? + .error_for_status()?; + Ok(resp.json().await?) + } + + /// Find a custom format by name + pub async fn find_custom_format_by_name(&self, name: &str) -> Result> { + let formats = self.get_custom_formats().await?; + Ok(formats.into_iter().find(|f| f.name == name)) + } + + /// Delete a custom format by ID + pub async fn delete_custom_format(&self, id: i32) -> Result<()> { + self.client + .delete(format!("{}/api/v3/customformat/{}", self.base_url, id)) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + // ========== Notifications ========== + + /// Get all notifications from Sonarr + pub async fn get_notifications(&self) -> Result> { + let resp = self + .client + .get(format!("{}/api/v3/notification", self.base_url)) + .send() + .await? + .error_for_status()?; + Ok(resp.json().await?) + } + + /// Find a notification by name + pub async fn find_notification_by_name(&self, name: &str) -> Result> { + let notifications = self.get_notifications().await?; + Ok(notifications.into_iter().find(|n| n.name == name)) + } + + /// Delete a notification by ID + pub async fn delete_notification(&self, id: i32) -> Result<()> { + self.client + .delete(format!("{}/api/v3/notification/{}", self.base_url, id)) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + // ========== Download Clients ========== + + /// Get all download clients from Sonarr + pub async fn get_download_clients(&self) -> Result> { + let resp = self + .client + .get(format!("{}/api/v3/downloadclient", self.base_url)) + .send() + .await? + .error_for_status()?; + Ok(resp.json().await?) + } + + /// Find a download client by name + pub async fn find_download_client_by_name(&self, name: &str) -> Result> { + let clients = self.get_download_clients().await?; + Ok(clients.into_iter().find(|c| c.name == name)) + } + + /// Delete a download client by ID + pub async fn delete_download_client(&self, id: i32) -> Result<()> { + self.client + .delete(format!("{}/api/v3/downloadclient/{}", self.base_url, id)) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + // ========== Indexers ========== + + /// Get all indexers from Sonarr + pub async fn get_indexers(&self) -> Result> { + let resp = self + .client + .get(format!("{}/api/v3/indexer", self.base_url)) + .send() + .await? + .error_for_status()?; + Ok(resp.json().await?) + } + + /// Find an indexer by name + pub async fn find_indexer_by_name(&self, name: &str) -> Result> { + let indexers = self.get_indexers().await?; + Ok(indexers.into_iter().find(|i| i.name == name)) + } + + /// Delete an indexer by ID + pub async fn delete_indexer(&self, id: i32) -> Result<()> { + self.client + .delete(format!("{}/api/v3/indexer/{}", self.base_url, id)) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + // ========== Auto Tags ========== + + /// Get all auto tags from Sonarr + pub async fn get_auto_tags(&self) -> Result> { + let resp = self + .client + .get(format!("{}/api/v3/autotag", self.base_url)) + .send() + .await? + .error_for_status()?; + Ok(resp.json().await?) + } + + /// Find an auto tag by name + pub async fn find_auto_tag_by_name(&self, name: &str) -> Result> { + let auto_tags = self.get_auto_tags().await?; + Ok(auto_tags.into_iter().find(|a| a.name == name)) + } + + /// Delete an auto tag by ID + pub async fn delete_auto_tag(&self, id: i32) -> Result<()> { + self.client + .delete(format!("{}/api/v3/autotag/{}", self.base_url, id)) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + // ========== Media Management Config ========== + + /// Get media management config + pub async fn get_media_management_config(&self) -> Result { + let resp = self + .client + .get(format!("{}/api/v3/config/mediamanagement", self.base_url)) + .send() + .await? + .error_for_status()?; + Ok(resp.json().await?) + } + + // ========== Naming Config ========== + + /// Get naming config + pub async fn get_naming_config(&self) -> Result { + let resp = self + .client + .get(format!("{}/api/v3/config/naming", self.base_url)) + .send() + .await? + .error_for_status()?; + Ok(resp.json().await?) + } + + // ========== Delay Profiles ========== + + /// Get all delay profiles from Sonarr + pub async fn get_delay_profiles(&self) -> Result> { + let resp = self + .client + .get(format!("{}/api/v3/delayprofile", self.base_url)) + .send() + .await? + .error_for_status()?; + Ok(resp.json().await?) + } + + /// Delete a delay profile by ID + pub async fn delete_delay_profile(&self, id: i32) -> Result<()> { + self.client + .delete(format!("{}/api/v3/delayprofile/{}", self.base_url, id)) + .send() + .await? + .error_for_status()?; + Ok(()) + } +} + +// ========== API Response Types ========== + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Tag { + pub id: i32, + pub label: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RootFolder { + pub id: i32, + pub path: String, + #[serde(default)] + pub accessible: bool, + #[serde(default)] + pub free_space: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QualityProfile { + pub id: i32, + pub name: String, + #[serde(default)] + pub upgrade_allowed: bool, + #[serde(default)] + pub cutoff: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CustomFormat { + pub id: i32, + pub name: String, + #[serde(default)] + pub include_custom_format_when_renaming: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Notification { + pub id: i32, + pub name: String, + pub implementation: String, + #[serde(default)] + pub on_grab: bool, + #[serde(default)] + pub on_download: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DownloadClient { + pub id: i32, + pub name: String, + pub implementation: String, + #[serde(default)] + pub enable: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Indexer { + pub id: i32, + pub name: String, + pub implementation: String, + #[serde(default)] + pub enable_rss: bool, + #[serde(default)] + pub enable_automatic_search: bool, + #[serde(default)] + pub enable_interactive_search: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AutoTag { + pub id: i32, + pub name: String, + #[serde(default)] + pub remove_tags_automatically: bool, + #[serde(default)] + pub tags: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MediaManagementConfig { + pub id: i32, + #[serde(default)] + pub auto_unmonitor_previously_downloaded_episodes: bool, + #[serde(default)] + pub recycle_bin: String, + #[serde(default)] + pub recycle_bin_cleanup_days: i32, + #[serde(default)] + pub create_empty_series_folders: bool, + #[serde(default)] + pub delete_empty_folders: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NamingConfig { + pub id: i32, + #[serde(default)] + pub rename_episodes: bool, + #[serde(default)] + pub replace_illegal_characters: bool, + #[serde(default)] + pub standard_episode_format: String, + #[serde(default)] + pub daily_episode_format: String, + #[serde(default)] + pub anime_episode_format: String, + #[serde(default)] + pub series_folder_format: String, + #[serde(default)] + pub season_folder_format: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DelayProfile { + pub id: i32, + #[serde(default)] + pub enable_usenet: bool, + #[serde(default)] + pub enable_torrent: bool, + #[serde(default)] + pub preferred_protocol: String, + #[serde(default)] + pub usenet_delay: i32, + #[serde(default)] + pub torrent_delay: i32, + #[serde(default)] + pub order: i32, + #[serde(default)] + pub tags: Vec, +} diff --git a/tests/integration/auto_tag_crd.rs b/tests/integration/auto_tag_crd.rs new file mode 100644 index 0000000..0fc1386 --- /dev/null +++ b/tests/integration/auto_tag_crd.rs @@ -0,0 +1,294 @@ +//! Integration tests for the SonarrAutoTag CRD + +use crate::common::*; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::api::{Api, DeleteParams, Patch, PatchParams}; +use sonarr_operator::crds::auto_tag::{ + AutoTagFields, AutoTagImplementation, AutoTagSpecification, SonarrAutoTagSpec, +}; +use sonarr_operator::crds::{SonarrAutoTag, SonarrInstanceRef}; + +/// Test that the SonarrAutoTag CRD is installed and established +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_sonarr_auto_tag_crd_is_established() { + let client = test_client().await; + + assert!( + is_crd_established(&client, "sonarrautotags.devopsarr.io").await, + "SonarrAutoTag CRD is not established - run 'make install' first" + ); +} + +/// Test creating a SonarrAutoTag resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_create_sonarr_auto_tag() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("auto-tag-test"); + let auto_tag = SonarrAutoTag { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrAutoTagSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + name: "Anime Auto Tag".to_string(), + remove_tags_automatically: true, + tags: vec![1, 2], + specifications: vec![ + AutoTagSpecification { + name: "Anime Genre".to_string(), + implementation: AutoTagImplementation::GenreSpecification, + negate: false, + required: true, + fields: AutoTagFields { + value: Some("Anime".to_string()), + min: None, + max: None, + }, + }, + AutoTagSpecification { + name: "Japanese Language".to_string(), + implementation: AutoTagImplementation::OriginalLanguageSpecification, + negate: false, + required: false, + fields: AutoTagFields { + value: Some("Japanese".to_string()), + min: None, + max: None, + }, + }, + ], + }, + status: None, + }; + + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + let result = api + .patch(&name, &patch_params, &Patch::Apply(&auto_tag)) + .await; + + assert!( + result.is_ok(), + "Failed to create SonarrAutoTag: {:?}", + result.err() + ); + + let retrieved = api.get(&name).await; + assert!( + retrieved.is_ok(), + "Failed to get SonarrAutoTag: {:?}", + retrieved.err() + ); + + let auto_tag = retrieved.unwrap(); + assert_eq!(auto_tag.spec.name, "Anime Auto Tag"); + assert!(auto_tag.spec.remove_tags_automatically); + assert_eq!(auto_tag.spec.tags.len(), 2); + assert_eq!(auto_tag.spec.specifications.len(), 2); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test updating a SonarrAutoTag resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_update_sonarr_auto_tag() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("auto-tag-update"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + // Create initial resource + let auto_tag = SonarrAutoTag { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrAutoTagSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + name: "Original Auto Tag".to_string(), + remove_tags_automatically: false, + tags: vec![1], + specifications: vec![], + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&auto_tag)) + .await + .expect("Failed to create SonarrAutoTag"); + + // Update the resource + let updated = SonarrAutoTag { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrAutoTagSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + name: "Updated Auto Tag".to_string(), + remove_tags_automatically: true, + tags: vec![1, 2, 3], + specifications: vec![AutoTagSpecification { + name: "Root Folder".to_string(), + implementation: AutoTagImplementation::RootFolderSpecification, + negate: false, + required: true, + fields: AutoTagFields { + value: Some("/tv/anime".to_string()), + min: None, + max: None, + }, + }], + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&updated)) + .await + .expect("Failed to update SonarrAutoTag"); + + let retrieved = api.get(&name).await.expect("Failed to get SonarrAutoTag"); + assert_eq!(retrieved.spec.name, "Updated Auto Tag"); + assert!(retrieved.spec.remove_tags_automatically); + assert_eq!(retrieved.spec.tags.len(), 3); + assert_eq!(retrieved.spec.specifications.len(), 1); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test deleting a SonarrAutoTag resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_delete_sonarr_auto_tag() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("auto-tag-delete"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + let auto_tag = SonarrAutoTag { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrAutoTagSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + name: "To Be Deleted".to_string(), + remove_tags_automatically: false, + tags: vec![], + specifications: vec![], + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&auto_tag)) + .await + .expect("Failed to create SonarrAutoTag"); + + let delete_result = api.delete(&name, &DeleteParams::default()).await; + assert!( + delete_result.is_ok(), + "Failed to delete SonarrAutoTag: {:?}", + delete_result.err() + ); + + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + let get_result = api.get(&name).await; + assert!( + get_result.is_err(), + "SonarrAutoTag should have been deleted" + ); +} + +/// Test creating auto tags with different specification types +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_create_auto_tags_with_different_specs() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + // Create an auto tag with year specification + let name = unique_name("auto-tag-year"); + let auto_tag = SonarrAutoTag { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrAutoTagSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + name: "Recent Shows".to_string(), + remove_tags_automatically: true, + tags: vec![1], + specifications: vec![AutoTagSpecification { + name: "Year Range".to_string(), + implementation: AutoTagImplementation::YearSpecification, + negate: false, + required: true, + fields: AutoTagFields { + value: None, + min: Some(2020), + max: Some(2030), + }, + }], + }, + status: None, + }; + + let result = api + .patch(&name, &patch_params, &Patch::Apply(&auto_tag)) + .await; + assert!( + result.is_ok(), + "Failed to create auto tag with year spec: {:?}", + result.err() + ); + + let retrieved = api.get(&name).await.expect("Failed to get auto tag"); + assert_eq!(retrieved.spec.specifications[0].fields.min, Some(2020)); + assert_eq!(retrieved.spec.specifications[0].fields.max, Some(2030)); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} diff --git a/tests/integration/common.rs b/tests/integration/common.rs new file mode 100644 index 0000000..4fc651c --- /dev/null +++ b/tests/integration/common.rs @@ -0,0 +1,207 @@ +//! Common utilities for integration tests + +use k8s_openapi::apiextensions_apiserver::pkg::apis::apiextensions::v1::CustomResourceDefinition; +use kube::{ + Client, Resource, + api::{Api, DeleteParams, ListParams, Patch, PatchParams}, +}; +use std::time::Duration; +use tokio::time::timeout; + +/// Default timeout for test operations +pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); + +/// Test namespace for integration tests +pub const TEST_NAMESPACE: &str = "sonarr-operator-test"; + +/// Create a test client from the current kubeconfig context +pub async fn test_client() -> Client { + Client::try_default() + .await + .expect("Failed to create Kubernetes client - is your kubeconfig configured?") +} + +/// Ensure the test namespace exists +pub async fn ensure_test_namespace(client: &Client) -> Result<(), kube::Error> { + use k8s_openapi::api::core::v1::Namespace; + use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; + + let namespaces: Api = Api::all(client.clone()); + + let ns = Namespace { + metadata: ObjectMeta { + name: Some(TEST_NAMESPACE.to_string()), + labels: Some( + [( + "app.kubernetes.io/managed-by".to_string(), + "sonarr-operator-test".to_string(), + )] + .into(), + ), + ..Default::default() + }, + ..Default::default() + }; + + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + namespaces + .patch(TEST_NAMESPACE, &patch_params, &Patch::Apply(&ns)) + .await?; + + Ok(()) +} + +/// Delete the test namespace and all its resources +pub async fn cleanup_test_namespace(client: &Client) -> Result<(), kube::Error> { + use k8s_openapi::api::core::v1::Namespace; + + let namespaces: Api = Api::all(client.clone()); + + match namespaces + .delete(TEST_NAMESPACE, &DeleteParams::default()) + .await + { + Ok(_) => { + // Wait for namespace to be deleted + let lp = ListParams::default().fields(&format!("metadata.name={}", TEST_NAMESPACE)); + let _ = timeout(Duration::from_secs(60), async { + loop { + match namespaces.list(&lp).await { + Ok(list) if list.items.is_empty() => break, + _ => tokio::time::sleep(Duration::from_secs(1)).await, + } + } + }) + .await; + } + Err(kube::Error::Api(err)) if err.code == 404 => { + // Namespace doesn't exist, that's fine + } + Err(e) => return Err(e), + } + + Ok(()) +} + +/// Check if a CRD is established (ready to use) +pub async fn is_crd_established(client: &Client, crd_name: &str) -> bool { + let crds: Api = Api::all(client.clone()); + + match crds.get(crd_name).await { + Ok(crd) => { + if let Some(status) = crd.status { + if let Some(conditions) = status.conditions { + return conditions + .iter() + .any(|c| c.type_ == "Established" && c.status == "True"); + } + } + false + } + Err(_) => false, + } +} + +/// Wait for a CRD to be established +pub async fn wait_for_crd(client: &Client, crd_name: &str) -> Result<(), String> { + timeout(DEFAULT_TIMEOUT, async { + loop { + if is_crd_established(client, crd_name).await { + return; + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + }) + .await + .map_err(|_| format!("Timeout waiting for CRD {} to be established", crd_name)) +} + +/// Apply a resource to the cluster +pub async fn apply_resource( + client: &Client, + namespace: &str, + resource: &T, +) -> Result +where + T: Resource + + Clone + + serde::Serialize + + serde::de::DeserializeOwned + + std::fmt::Debug, + ::DynamicType: Default, +{ + let api: Api = Api::namespaced(client.clone(), namespace); + let name = resource.meta().name.clone().unwrap_or_default(); + + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + api.patch(&name, &patch_params, &Patch::Apply(resource)) + .await +} + +/// Delete a resource from the cluster +pub async fn delete_resource( + client: &Client, + namespace: &str, + name: &str, +) -> Result<(), kube::Error> +where + T: Resource + + Clone + + serde::Serialize + + serde::de::DeserializeOwned + + std::fmt::Debug, + ::DynamicType: Default, +{ + let api: Api = Api::namespaced(client.clone(), namespace); + + match api.delete(name, &DeleteParams::default()).await { + Ok(_) => Ok(()), + Err(kube::Error::Api(err)) if err.code == 404 => Ok(()), // Already deleted + Err(e) => Err(e), + } +} + +/// Wait for a resource to have a specific condition +pub async fn wait_for_condition( + client: &Client, + namespace: &str, + name: &str, + condition_fn: F, +) -> Result +where + T: Resource + + Clone + + serde::Serialize + + serde::de::DeserializeOwned + + std::fmt::Debug, + ::DynamicType: Default, + F: Fn(&T) -> bool, +{ + let api: Api = Api::namespaced(client.clone(), namespace); + + timeout(DEFAULT_TIMEOUT, async { + loop { + match api.get(name).await { + Ok(resource) => { + if condition_fn(&resource) { + return Ok(resource); + } + } + Err(_) => {} + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + }) + .await + .map_err(|_| format!("Timeout waiting for condition on {}", name))? +} + +/// Generate a unique test name to avoid conflicts between test runs +pub fn unique_name(prefix: &str) -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis(); + format!("{}-{}", prefix, timestamp % 100000) +} diff --git a/tests/integration/custom_format_crd.rs b/tests/integration/custom_format_crd.rs new file mode 100644 index 0000000..7d36a89 --- /dev/null +++ b/tests/integration/custom_format_crd.rs @@ -0,0 +1,230 @@ +//! Integration tests for the SonarrCustomFormat CRD + +use crate::common::*; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::api::{Api, DeleteParams, Patch, PatchParams}; +use sonarr_operator::crds::custom_format::{ + CustomFormatFields, CustomFormatImplementation, CustomFormatSpecification, + SonarrCustomFormatSpec, +}; +use sonarr_operator::crds::{SonarrCustomFormat, SonarrInstanceRef}; + +/// Test that the SonarrCustomFormat CRD is installed and established +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_sonarr_custom_format_crd_is_established() { + let client = test_client().await; + + assert!( + is_crd_established(&client, "sonarrcustomformats.devopsarr.io").await, + "SonarrCustomFormat CRD is not established - run 'make install' first" + ); +} + +/// Test creating a SonarrCustomFormat resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_create_sonarr_custom_format() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("custom-format-test"); + let cf = SonarrCustomFormat { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrCustomFormatSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + name: "4K HDR".to_string(), + include_custom_format_when_renaming: true, + specifications: vec![ + CustomFormatSpecification { + name: "4K".to_string(), + implementation: CustomFormatImplementation::ResolutionSpecification, + negate: false, + required: true, + fields: CustomFormatFields { + value: Some("2160".to_string()), + min: None, + max: None, + }, + }, + CustomFormatSpecification { + name: "HDR".to_string(), + implementation: CustomFormatImplementation::ReleaseTitleSpecification, + negate: false, + required: true, + fields: CustomFormatFields { + value: Some("HDR".to_string()), + min: None, + max: None, + }, + }, + ], + }, + status: None, + }; + + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + let result = api.patch(&name, &patch_params, &Patch::Apply(&cf)).await; + + assert!( + result.is_ok(), + "Failed to create SonarrCustomFormat: {:?}", + result.err() + ); + + let retrieved = api.get(&name).await; + assert!( + retrieved.is_ok(), + "Failed to get SonarrCustomFormat: {:?}", + retrieved.err() + ); + + let cf = retrieved.unwrap(); + assert_eq!(cf.spec.name, "4K HDR"); + assert!(cf.spec.include_custom_format_when_renaming); + assert_eq!(cf.spec.specifications.len(), 2); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test updating a SonarrCustomFormat resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_update_sonarr_custom_format() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("custom-format-update"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + // Create initial resource + let cf = SonarrCustomFormat { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrCustomFormatSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + name: "Original Format".to_string(), + include_custom_format_when_renaming: false, + specifications: vec![], + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&cf)) + .await + .expect("Failed to create SonarrCustomFormat"); + + // Update the resource + let updated = SonarrCustomFormat { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrCustomFormatSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + name: "Updated Format".to_string(), + include_custom_format_when_renaming: true, + specifications: vec![CustomFormatSpecification { + name: "x265".to_string(), + implementation: CustomFormatImplementation::ReleaseTitleSpecification, + negate: false, + required: true, + fields: CustomFormatFields { + value: Some("x265|HEVC".to_string()), + min: None, + max: None, + }, + }], + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&updated)) + .await + .expect("Failed to update SonarrCustomFormat"); + + let retrieved = api + .get(&name) + .await + .expect("Failed to get SonarrCustomFormat"); + assert_eq!(retrieved.spec.name, "Updated Format"); + assert!(retrieved.spec.include_custom_format_when_renaming); + assert_eq!(retrieved.spec.specifications.len(), 1); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test deleting a SonarrCustomFormat resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_delete_sonarr_custom_format() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("custom-format-delete"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + let cf = SonarrCustomFormat { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrCustomFormatSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + name: "To Be Deleted".to_string(), + include_custom_format_when_renaming: false, + specifications: vec![], + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&cf)) + .await + .expect("Failed to create SonarrCustomFormat"); + + let delete_result = api.delete(&name, &DeleteParams::default()).await; + assert!( + delete_result.is_ok(), + "Failed to delete SonarrCustomFormat: {:?}", + delete_result.err() + ); + + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + let get_result = api.get(&name).await; + assert!( + get_result.is_err(), + "SonarrCustomFormat should have been deleted" + ); +} diff --git a/tests/integration/delay_profile_crd.rs b/tests/integration/delay_profile_crd.rs new file mode 100644 index 0000000..3ef358c --- /dev/null +++ b/tests/integration/delay_profile_crd.rs @@ -0,0 +1,225 @@ +//! Integration tests for the SonarrDelayProfile CRD + +use crate::common::*; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::api::{Api, DeleteParams, Patch, PatchParams}; +use sonarr_operator::crds::delay_profile::{DownloadProtocol, SonarrDelayProfileSpec}; +use sonarr_operator::crds::{SonarrDelayProfile, SonarrInstanceRef}; + +/// Test that the SonarrDelayProfile CRD is installed and established +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_sonarr_delay_profile_crd_is_established() { + let client = test_client().await; + + assert!( + is_crd_established(&client, "sonarrdelayprofiles.devopsarr.io").await, + "SonarrDelayProfile CRD is not established - run 'make install' first" + ); +} + +/// Test creating a SonarrDelayProfile resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_create_sonarr_delay_profile() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("delay-profile-test"); + let profile = SonarrDelayProfile { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrDelayProfileSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + enable_usenet: true, + enable_torrent: true, + preferred_protocol: DownloadProtocol::Usenet, + usenet_delay: 0, + torrent_delay: 120, + bypass_if_highest_quality: true, + bypass_if_above_custom_format_score: false, + minimum_custom_format_score: 0, + order: 1, + tags: vec![], + }, + status: None, + }; + + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + let result = api + .patch(&name, &patch_params, &Patch::Apply(&profile)) + .await; + + assert!( + result.is_ok(), + "Failed to create SonarrDelayProfile: {:?}", + result.err() + ); + + let retrieved = api.get(&name).await; + assert!( + retrieved.is_ok(), + "Failed to get SonarrDelayProfile: {:?}", + retrieved.err() + ); + + let profile = retrieved.unwrap(); + assert!(profile.spec.enable_usenet); + assert_eq!(profile.spec.torrent_delay, 120); + assert!(profile.spec.bypass_if_highest_quality); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test updating a SonarrDelayProfile resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_update_sonarr_delay_profile() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("delay-profile-update"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + // Create initial resource + let profile = SonarrDelayProfile { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrDelayProfileSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + enable_usenet: true, + enable_torrent: false, + preferred_protocol: DownloadProtocol::Usenet, + usenet_delay: 0, + torrent_delay: 0, + bypass_if_highest_quality: false, + bypass_if_above_custom_format_score: false, + minimum_custom_format_score: 0, + order: 1, + tags: vec![], + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&profile)) + .await + .expect("Failed to create SonarrDelayProfile"); + + // Update the resource + let updated = SonarrDelayProfile { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrDelayProfileSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + enable_usenet: false, + enable_torrent: true, + preferred_protocol: DownloadProtocol::Torrent, + usenet_delay: 60, + torrent_delay: 30, + bypass_if_highest_quality: true, + bypass_if_above_custom_format_score: true, + minimum_custom_format_score: 100, + order: 2, + tags: vec![1, 2, 3], + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&updated)) + .await + .expect("Failed to update SonarrDelayProfile"); + + let retrieved = api + .get(&name) + .await + .expect("Failed to get SonarrDelayProfile"); + assert!(!retrieved.spec.enable_usenet); + assert!(retrieved.spec.enable_torrent); + assert_eq!(retrieved.spec.torrent_delay, 30); + assert_eq!(retrieved.spec.minimum_custom_format_score, 100); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test deleting a SonarrDelayProfile resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_delete_sonarr_delay_profile() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("delay-profile-delete"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + let profile = SonarrDelayProfile { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrDelayProfileSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + enable_usenet: true, + enable_torrent: true, + preferred_protocol: DownloadProtocol::Usenet, + usenet_delay: 0, + torrent_delay: 0, + bypass_if_highest_quality: false, + bypass_if_above_custom_format_score: false, + minimum_custom_format_score: 0, + order: 1, + tags: vec![], + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&profile)) + .await + .expect("Failed to create SonarrDelayProfile"); + + let delete_result = api.delete(&name, &DeleteParams::default()).await; + assert!( + delete_result.is_ok(), + "Failed to delete SonarrDelayProfile: {:?}", + delete_result.err() + ); + + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + let get_result = api.get(&name).await; + assert!( + get_result.is_err(), + "SonarrDelayProfile should have been deleted" + ); +} diff --git a/tests/integration/download_client_config_crd.rs b/tests/integration/download_client_config_crd.rs new file mode 100644 index 0000000..c7d4316 --- /dev/null +++ b/tests/integration/download_client_config_crd.rs @@ -0,0 +1,190 @@ +//! Integration tests for the SonarrDownloadClientConfig CRD + +use crate::common::*; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::api::{Api, DeleteParams, Patch, PatchParams}; +use sonarr_operator::crds::download_client_config::SonarrDownloadClientConfigSpec; +use sonarr_operator::crds::{SonarrDownloadClientConfig, SonarrInstanceRef}; + +/// Test that the SonarrDownloadClientConfig CRD is installed and established +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_sonarr_download_client_config_crd_is_established() { + let client = test_client().await; + + assert!( + is_crd_established(&client, "sonarrdownloadclientconfigs.devopsarr.io").await, + "SonarrDownloadClientConfig CRD is not established - run 'make install' first" + ); +} + +/// Test creating a SonarrDownloadClientConfig resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_create_sonarr_download_client_config() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("dlclient-cfg-test"); + let config = SonarrDownloadClientConfig { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrDownloadClientConfigSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + enable_completed_download_handling: Some(true), + auto_redownload_failed: Some(true), + auto_redownload_failed_from_interactive_search: Some(false), + download_client_working_folders: None, + }, + status: None, + }; + + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + let result = api.patch(&name, &patch_params, &Patch::Apply(&config)).await; + + assert!( + result.is_ok(), + "Failed to create SonarrDownloadClientConfig: {:?}", + result.err() + ); + + let retrieved = api.get(&name).await; + assert!( + retrieved.is_ok(), + "Failed to get SonarrDownloadClientConfig: {:?}", + retrieved.err() + ); + + let config = retrieved.unwrap(); + assert_eq!(config.spec.enable_completed_download_handling, Some(true)); + assert_eq!(config.spec.auto_redownload_failed, Some(true)); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test updating a SonarrDownloadClientConfig resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_update_sonarr_download_client_config() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("dlclient-cfg-update"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + // Create initial resource + let config = SonarrDownloadClientConfig { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrDownloadClientConfigSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + enable_completed_download_handling: Some(false), + ..Default::default() + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&config)) + .await + .expect("Failed to create SonarrDownloadClientConfig"); + + // Update the resource + let updated = SonarrDownloadClientConfig { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrDownloadClientConfigSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + enable_completed_download_handling: Some(true), + auto_redownload_failed: Some(true), + ..Default::default() + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&updated)) + .await + .expect("Failed to update SonarrDownloadClientConfig"); + + let retrieved = api + .get(&name) + .await + .expect("Failed to get SonarrDownloadClientConfig"); + assert_eq!(retrieved.spec.enable_completed_download_handling, Some(true)); + assert_eq!(retrieved.spec.auto_redownload_failed, Some(true)); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test deleting a SonarrDownloadClientConfig resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_delete_sonarr_download_client_config() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("dlclient-cfg-delete"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + let config = SonarrDownloadClientConfig { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrDownloadClientConfigSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + ..Default::default() + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&config)) + .await + .expect("Failed to create SonarrDownloadClientConfig"); + + let delete_result = api.delete(&name, &DeleteParams::default()).await; + assert!( + delete_result.is_ok(), + "Failed to delete SonarrDownloadClientConfig: {:?}", + delete_result.err() + ); + + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + let get_result = api.get(&name).await; + assert!( + get_result.is_err(), + "SonarrDownloadClientConfig should have been deleted" + ); +} diff --git a/tests/integration/import_list_crd.rs b/tests/integration/import_list_crd.rs new file mode 100644 index 0000000..ac01185 --- /dev/null +++ b/tests/integration/import_list_crd.rs @@ -0,0 +1,236 @@ +//! Integration tests for the SonarrImportList CRD + +use crate::common::*; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::api::{Api, DeleteParams, Patch, PatchParams}; +use sonarr_operator::crds::import_list::{ + ImportListType, MonitorTypes, NewItemMonitorTypes, SeriesTypes, SonarrImportListSpec, +}; +use sonarr_operator::crds::{SonarrImportList, SonarrInstanceRef}; + +/// Test that the SonarrImportList CRD is installed and established +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_sonarr_import_list_crd_is_established() { + let client = test_client().await; + + assert!( + is_crd_established(&client, "sonarrimportlists.devopsarr.io").await, + "SonarrImportList CRD is not established - run 'make install' first" + ); +} + +/// Test creating a SonarrImportList resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_create_sonarr_import_list() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("import-list-test"); + let import_list = SonarrImportList { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrImportListSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + name: "Test Import List".to_string(), + list_type: ImportListType::TraktListImport, + enable_automatic_add: true, + search_for_missing_episodes: false, + should_monitor: MonitorTypes::All, + monitor_new_items: NewItemMonitorTypes::All, + root_folder_path: "/tv".to_string(), + quality_profile_id: 1, + series_type: SeriesTypes::Standard, + season_folder: true, + list_order: 0, + tags: vec![], + config: Default::default(), + }, + status: None, + }; + + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + let result = api + .patch(&name, &patch_params, &Patch::Apply(&import_list)) + .await; + + assert!( + result.is_ok(), + "Failed to create SonarrImportList: {:?}", + result.err() + ); + + let retrieved = api.get(&name).await; + assert!( + retrieved.is_ok(), + "Failed to get SonarrImportList: {:?}", + retrieved.err() + ); + + let import_list = retrieved.unwrap(); + assert_eq!(import_list.spec.name, "Test Import List"); + assert_eq!(import_list.spec.root_folder_path, "/tv"); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test updating a SonarrImportList resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_update_sonarr_import_list() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("import-list-update"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + // Create initial resource + let import_list = SonarrImportList { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrImportListSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + name: "Original Import List".to_string(), + list_type: ImportListType::TraktListImport, + enable_automatic_add: true, + search_for_missing_episodes: false, + should_monitor: MonitorTypes::All, + monitor_new_items: NewItemMonitorTypes::All, + root_folder_path: "/tv".to_string(), + quality_profile_id: 1, + series_type: SeriesTypes::Standard, + season_folder: true, + list_order: 0, + tags: vec![], + config: Default::default(), + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&import_list)) + .await + .expect("Failed to create SonarrImportList"); + + // Update the resource + let updated = SonarrImportList { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrImportListSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + name: "Updated Import List".to_string(), + list_type: ImportListType::PlexImport, + enable_automatic_add: false, + search_for_missing_episodes: true, + should_monitor: MonitorTypes::Future, + monitor_new_items: NewItemMonitorTypes::None, + root_folder_path: "/movies".to_string(), + quality_profile_id: 2, + series_type: SeriesTypes::Anime, + season_folder: false, + list_order: 1, + tags: vec![1, 2], + config: Default::default(), + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&updated)) + .await + .expect("Failed to update SonarrImportList"); + + let retrieved = api + .get(&name) + .await + .expect("Failed to get SonarrImportList"); + assert_eq!(retrieved.spec.name, "Updated Import List"); + assert_eq!(retrieved.spec.root_folder_path, "/movies"); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test deleting a SonarrImportList resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_delete_sonarr_import_list() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("import-list-delete"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + let import_list = SonarrImportList { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrImportListSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + name: "To Be Deleted".to_string(), + list_type: ImportListType::SonarrImport, + enable_automatic_add: true, + search_for_missing_episodes: false, + should_monitor: MonitorTypes::All, + monitor_new_items: NewItemMonitorTypes::All, + root_folder_path: "/tv".to_string(), + quality_profile_id: 1, + series_type: SeriesTypes::Standard, + season_folder: true, + list_order: 0, + tags: vec![], + config: Default::default(), + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&import_list)) + .await + .expect("Failed to create SonarrImportList"); + + let delete_result = api.delete(&name, &DeleteParams::default()).await; + assert!( + delete_result.is_ok(), + "Failed to delete SonarrImportList: {:?}", + delete_result.err() + ); + + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + let get_result = api.get(&name).await; + assert!( + get_result.is_err(), + "SonarrImportList should have been deleted" + ); +} diff --git a/tests/integration/indexer_config_crd.rs b/tests/integration/indexer_config_crd.rs new file mode 100644 index 0000000..65a9e78 --- /dev/null +++ b/tests/integration/indexer_config_crd.rs @@ -0,0 +1,189 @@ +//! Integration tests for the SonarrIndexerConfig CRD + +use crate::common::*; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::api::{Api, DeleteParams, Patch, PatchParams}; +use sonarr_operator::crds::indexer_config::SonarrIndexerConfigSpec; +use sonarr_operator::crds::{SonarrIndexerConfig, SonarrInstanceRef}; + +/// Test that the SonarrIndexerConfig CRD is installed and established +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_sonarr_indexer_config_crd_is_established() { + let client = test_client().await; + + assert!( + is_crd_established(&client, "sonarrindexerconfigs.devopsarr.io").await, + "SonarrIndexerConfig CRD is not established - run 'make install' first" + ); +} + +/// Test creating a SonarrIndexerConfig resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_create_sonarr_indexer_config() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("indexer-cfg-test"); + let config = SonarrIndexerConfig { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrIndexerConfigSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + minimum_age: Some(0), + retention: Some(0), + maximum_size: Some(0), + rss_sync_interval: Some(60), + }, + status: None, + }; + + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + let result = api.patch(&name, &patch_params, &Patch::Apply(&config)).await; + + assert!( + result.is_ok(), + "Failed to create SonarrIndexerConfig: {:?}", + result.err() + ); + + let retrieved = api.get(&name).await; + assert!( + retrieved.is_ok(), + "Failed to get SonarrIndexerConfig: {:?}", + retrieved.err() + ); + + let config = retrieved.unwrap(); + assert_eq!(config.spec.rss_sync_interval, Some(60)); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test updating a SonarrIndexerConfig resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_update_sonarr_indexer_config() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("indexer-cfg-update"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + // Create initial resource + let config = SonarrIndexerConfig { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrIndexerConfigSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + rss_sync_interval: Some(30), + ..Default::default() + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&config)) + .await + .expect("Failed to create SonarrIndexerConfig"); + + // Update the resource + let updated = SonarrIndexerConfig { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrIndexerConfigSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + rss_sync_interval: Some(120), + maximum_size: Some(500), + ..Default::default() + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&updated)) + .await + .expect("Failed to update SonarrIndexerConfig"); + + let retrieved = api + .get(&name) + .await + .expect("Failed to get SonarrIndexerConfig"); + assert_eq!(retrieved.spec.rss_sync_interval, Some(120)); + assert_eq!(retrieved.spec.maximum_size, Some(500)); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test deleting a SonarrIndexerConfig resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_delete_sonarr_indexer_config() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("indexer-cfg-delete"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + let config = SonarrIndexerConfig { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrIndexerConfigSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + ..Default::default() + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&config)) + .await + .expect("Failed to create SonarrIndexerConfig"); + + let delete_result = api.delete(&name, &DeleteParams::default()).await; + assert!( + delete_result.is_ok(), + "Failed to delete SonarrIndexerConfig: {:?}", + delete_result.err() + ); + + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + let get_result = api.get(&name).await; + assert!( + get_result.is_err(), + "SonarrIndexerConfig should have been deleted" + ); +} diff --git a/tests/integration/language_profile_crd.rs b/tests/integration/language_profile_crd.rs new file mode 100644 index 0000000..252571f --- /dev/null +++ b/tests/integration/language_profile_crd.rs @@ -0,0 +1,223 @@ +//! Integration tests for the SonarrLanguageProfile CRD + +use crate::common::*; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::api::{Api, DeleteParams, Patch, PatchParams}; +use sonarr_operator::crds::language_profile::{ + LanguageItem, LanguageType, SonarrLanguageProfileSpec, +}; +use sonarr_operator::crds::{SonarrInstanceRef, SonarrLanguageProfile}; + +/// Test that the SonarrLanguageProfile CRD is installed and established +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_sonarr_language_profile_crd_is_established() { + let client = test_client().await; + + assert!( + is_crd_established(&client, "sonarrlanguageprofiles.devopsarr.io").await, + "SonarrLanguageProfile CRD is not established - run 'make install' first" + ); +} + +/// Test creating a SonarrLanguageProfile resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_create_sonarr_language_profile() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("lang-profile-test"); + let profile = SonarrLanguageProfile { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrLanguageProfileSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + name: "Test Language Profile".to_string(), + upgrade_allowed: true, + cutoff_language: LanguageType::English, + languages: vec![ + LanguageItem { + language: LanguageType::English, + allowed: true, + }, + LanguageItem { + language: LanguageType::French, + allowed: true, + }, + ], + }, + status: None, + }; + + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + let result = api + .patch(&name, &patch_params, &Patch::Apply(&profile)) + .await; + + assert!( + result.is_ok(), + "Failed to create SonarrLanguageProfile: {:?}", + result.err() + ); + + let retrieved = api.get(&name).await; + assert!( + retrieved.is_ok(), + "Failed to get SonarrLanguageProfile: {:?}", + retrieved.err() + ); + + let profile = retrieved.unwrap(); + assert_eq!(profile.spec.name, "Test Language Profile"); + assert!(profile.spec.upgrade_allowed); + assert_eq!(profile.spec.languages.len(), 2); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test updating a SonarrLanguageProfile resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_update_sonarr_language_profile() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("lang-profile-update"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + // Create initial resource + let profile = SonarrLanguageProfile { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrLanguageProfileSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + name: "Original Profile".to_string(), + upgrade_allowed: false, + cutoff_language: LanguageType::English, + languages: vec![LanguageItem { + language: LanguageType::English, + allowed: true, + }], + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&profile)) + .await + .expect("Failed to create SonarrLanguageProfile"); + + // Update the resource + let updated = SonarrLanguageProfile { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrLanguageProfileSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + name: "Updated Profile".to_string(), + upgrade_allowed: true, + cutoff_language: LanguageType::Japanese, + languages: vec![ + LanguageItem { + language: LanguageType::Japanese, + allowed: true, + }, + LanguageItem { + language: LanguageType::English, + allowed: true, + }, + ], + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&updated)) + .await + .expect("Failed to update SonarrLanguageProfile"); + + let retrieved = api + .get(&name) + .await + .expect("Failed to get SonarrLanguageProfile"); + assert_eq!(retrieved.spec.name, "Updated Profile"); + assert!(retrieved.spec.upgrade_allowed); + assert_eq!(retrieved.spec.languages.len(), 2); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test deleting a SonarrLanguageProfile resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_delete_sonarr_language_profile() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("lang-profile-delete"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + let profile = SonarrLanguageProfile { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrLanguageProfileSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + name: "To Be Deleted".to_string(), + upgrade_allowed: false, + cutoff_language: LanguageType::English, + languages: vec![], + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&profile)) + .await + .expect("Failed to create SonarrLanguageProfile"); + + let delete_result = api.delete(&name, &DeleteParams::default()).await; + assert!( + delete_result.is_ok(), + "Failed to delete SonarrLanguageProfile: {:?}", + delete_result.err() + ); + + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + let get_result = api.get(&name).await; + assert!( + get_result.is_err(), + "SonarrLanguageProfile should have been deleted" + ); +} diff --git a/tests/integration/main.rs b/tests/integration/main.rs new file mode 100644 index 0000000..3bfc3e2 --- /dev/null +++ b/tests/integration/main.rs @@ -0,0 +1,25 @@ +//! Integration tests for the Sonarr Kubernetes Operator +//! +//! These tests require a running Kubernetes cluster accessible via the current kubeconfig context. +//! Run with: `cargo test --test integration -- --ignored` +//! +//! Prerequisites: +//! 1. A running Kubernetes cluster (e.g., kind, k3d, minikube) +//! 2. CRDs installed: `make install` +//! 3. Current kubeconfig context set to the test cluster + +mod auto_tag_crd; +mod common; +mod custom_format_crd; +mod delay_profile_crd; +mod download_client_config_crd; +mod import_list_crd; +mod indexer_config_crd; +mod language_profile_crd; +mod media_management_config_crd; +mod metadata_crd; +mod naming_config_crd; +mod quality_definition_crd; +mod root_folder_crd; +mod sonarr_crd; +mod tag_crd; diff --git a/tests/integration/media_management_config_crd.rs b/tests/integration/media_management_config_crd.rs new file mode 100644 index 0000000..3ff3cad --- /dev/null +++ b/tests/integration/media_management_config_crd.rs @@ -0,0 +1,269 @@ +//! Integration tests for the SonarrMediaManagementConfig CRD + +use crate::common::*; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::api::{Api, DeleteParams, Patch, PatchParams}; +use sonarr_operator::crds::media_management_config::SonarrMediaManagementConfigSpec; +use sonarr_operator::crds::{SonarrInstanceRef, SonarrMediaManagementConfig}; + +/// Test that the SonarrMediaManagementConfig CRD is installed and established +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_sonarr_media_management_config_crd_is_established() { + let client = test_client().await; + + assert!( + is_crd_established(&client, "sonarrmediamanagementconfigs.devopsarr.io").await, + "SonarrMediaManagementConfig CRD is not established - run 'make install' first" + ); +} + +/// Test creating a SonarrMediaManagementConfig resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_create_sonarr_media_management_config() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("media-mgmt-test"); + let config = SonarrMediaManagementConfig { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrMediaManagementConfigSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + recycle_bin: Some("/data/recycle".to_string()), + recycle_bin_cleanup_days: Some(7), + copy_using_hardlinks: Some(true), + create_empty_series_folders: Some(false), + delete_empty_folders: Some(true), + ..Default::default() + }, + status: None, + }; + + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + let result = api.patch(&name, &patch_params, &Patch::Apply(&config)).await; + + assert!( + result.is_ok(), + "Failed to create SonarrMediaManagementConfig: {:?}", + result.err() + ); + + let retrieved = api.get(&name).await; + assert!( + retrieved.is_ok(), + "Failed to get SonarrMediaManagementConfig: {:?}", + retrieved.err() + ); + + let config = retrieved.unwrap(); + assert_eq!(config.spec.recycle_bin, Some("/data/recycle".to_string())); + assert_eq!(config.spec.recycle_bin_cleanup_days, Some(7)); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test updating a SonarrMediaManagementConfig resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_update_sonarr_media_management_config() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("media-mgmt-update"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + // Create initial resource + let config = SonarrMediaManagementConfig { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrMediaManagementConfigSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + recycle_bin_cleanup_days: Some(7), + ..Default::default() + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&config)) + .await + .expect("Failed to create SonarrMediaManagementConfig"); + + // Update the resource + let updated = SonarrMediaManagementConfig { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrMediaManagementConfigSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + recycle_bin_cleanup_days: Some(14), + copy_using_hardlinks: Some(false), + ..Default::default() + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&updated)) + .await + .expect("Failed to update SonarrMediaManagementConfig"); + + let retrieved = api + .get(&name) + .await + .expect("Failed to get SonarrMediaManagementConfig"); + assert_eq!(retrieved.spec.recycle_bin_cleanup_days, Some(14)); + assert_eq!(retrieved.spec.copy_using_hardlinks, Some(false)); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test deleting a SonarrMediaManagementConfig resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_delete_sonarr_media_management_config() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("media-mgmt-delete"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + let config = SonarrMediaManagementConfig { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrMediaManagementConfigSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + ..Default::default() + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&config)) + .await + .expect("Failed to create SonarrMediaManagementConfig"); + + let delete_result = api.delete(&name, &DeleteParams::default()).await; + assert!( + delete_result.is_ok(), + "Failed to delete SonarrMediaManagementConfig: {:?}", + delete_result.err() + ); + + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + let get_result = api.get(&name).await; + assert!( + get_result.is_err(), + "SonarrMediaManagementConfig should have been deleted" + ); +} + +/// Test that only one SonarrMediaManagementConfig per Sonarr instance can be created +/// The second one should be marked as conflict by the controller +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_singleton_constraint() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let instance_name = unique_name("singleton-instance"); + let name1 = unique_name("media-mgmt-first"); + let name2 = unique_name("media-mgmt-second"); + + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + // Create first config + let config1 = SonarrMediaManagementConfig { + metadata: ObjectMeta { + name: Some(name1.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrMediaManagementConfigSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: instance_name.clone(), + namespace: None, + }, + recycle_bin_cleanup_days: Some(7), + ..Default::default() + }, + status: None, + }; + + api.patch(&name1, &patch_params, &Patch::Apply(&config1)) + .await + .expect("Failed to create first SonarrMediaManagementConfig"); + + // Create second config for the same instance + let config2 = SonarrMediaManagementConfig { + metadata: ObjectMeta { + name: Some(name2.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrMediaManagementConfigSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: instance_name.clone(), + namespace: None, + }, + recycle_bin_cleanup_days: Some(14), + ..Default::default() + }, + status: None, + }; + + // This should succeed at the K8s level (resource is created) + // but the controller should mark it as conflict + let result = api.patch(&name2, &patch_params, &Patch::Apply(&config2)).await; + assert!( + result.is_ok(), + "Failed to create second SonarrMediaManagementConfig: {:?}", + result.err() + ); + + // Both resources should exist in K8s + let retrieved1 = api.get(&name1).await; + let retrieved2 = api.get(&name2).await; + assert!(retrieved1.is_ok(), "First config should exist"); + assert!(retrieved2.is_ok(), "Second config should exist"); + + // Cleanup + let _ = api.delete(&name1, &DeleteParams::default()).await; + let _ = api.delete(&name2, &DeleteParams::default()).await; +} diff --git a/tests/integration/metadata_crd.rs b/tests/integration/metadata_crd.rs new file mode 100644 index 0000000..911d1e9 --- /dev/null +++ b/tests/integration/metadata_crd.rs @@ -0,0 +1,213 @@ +//! Integration tests for the SonarrMetadata CRD + +use crate::common::*; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::api::{Api, DeleteParams, Patch, PatchParams}; +use sonarr_operator::crds::metadata::{MetadataConfig, MetadataType, SonarrMetadataSpec}; +use sonarr_operator::crds::{SonarrInstanceRef, SonarrMetadata}; + +/// Test that the SonarrMetadata CRD is installed and established +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_sonarr_metadata_crd_is_established() { + let client = test_client().await; + + assert!( + is_crd_established(&client, "sonarrmetadatas.devopsarr.io").await, + "SonarrMetadata CRD is not established - run 'make install' first" + ); +} + +/// Test creating a SonarrMetadata resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_create_sonarr_metadata() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("metadata-test"); + let metadata_res = SonarrMetadata { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrMetadataSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + name: "Kodi Metadata".to_string(), + metadata_type: MetadataType::XbmcMetadata, + enable: true, + tags: vec![], + config: MetadataConfig { + series_metadata: true, + series_metadata_url: false, + episode_metadata: true, + series_images: true, + season_images: true, + episode_images: false, + }, + }, + status: None, + }; + + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + let result = api + .patch(&name, &patch_params, &Patch::Apply(&metadata_res)) + .await; + + assert!( + result.is_ok(), + "Failed to create SonarrMetadata: {:?}", + result.err() + ); + + let retrieved = api.get(&name).await; + assert!( + retrieved.is_ok(), + "Failed to get SonarrMetadata: {:?}", + retrieved.err() + ); + + let metadata_res = retrieved.unwrap(); + assert_eq!(metadata_res.spec.name, "Kodi Metadata"); + assert!(metadata_res.spec.enable); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test updating a SonarrMetadata resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_update_sonarr_metadata() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("metadata-update"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + // Create initial resource + let metadata_res = SonarrMetadata { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrMetadataSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + name: "Original Metadata".to_string(), + metadata_type: MetadataType::XbmcMetadata, + enable: true, + tags: vec![], + config: MetadataConfig::default(), + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&metadata_res)) + .await + .expect("Failed to create SonarrMetadata"); + + // Update the resource + let updated = SonarrMetadata { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrMetadataSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + name: "Updated Metadata".to_string(), + metadata_type: MetadataType::RoksboxMetadata, + enable: false, + tags: vec![1], + config: MetadataConfig { + series_metadata: false, + series_metadata_url: true, + episode_metadata: false, + series_images: false, + season_images: false, + episode_images: true, + }, + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&updated)) + .await + .expect("Failed to update SonarrMetadata"); + + let retrieved = api.get(&name).await.expect("Failed to get SonarrMetadata"); + assert_eq!(retrieved.spec.name, "Updated Metadata"); + assert!(!retrieved.spec.enable); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test deleting a SonarrMetadata resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_delete_sonarr_metadata() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("metadata-delete"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + let metadata_res = SonarrMetadata { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrMetadataSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + name: "To Be Deleted".to_string(), + metadata_type: MetadataType::WdtvMetadata, + enable: true, + tags: vec![], + config: MetadataConfig::default(), + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&metadata_res)) + .await + .expect("Failed to create SonarrMetadata"); + + let delete_result = api.delete(&name, &DeleteParams::default()).await; + assert!( + delete_result.is_ok(), + "Failed to delete SonarrMetadata: {:?}", + delete_result.err() + ); + + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + let get_result = api.get(&name).await; + assert!( + get_result.is_err(), + "SonarrMetadata should have been deleted" + ); +} diff --git a/tests/integration/naming_config_crd.rs b/tests/integration/naming_config_crd.rs new file mode 100644 index 0000000..21726c8 --- /dev/null +++ b/tests/integration/naming_config_crd.rs @@ -0,0 +1,199 @@ +//! Integration tests for the SonarrNamingConfig CRD + +use crate::common::*; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::api::{Api, DeleteParams, Patch, PatchParams}; +use sonarr_operator::crds::naming_config::SonarrNamingConfigSpec; +use sonarr_operator::crds::{SonarrInstanceRef, SonarrNamingConfig}; + +/// Test that the SonarrNamingConfig CRD is installed and established +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_sonarr_naming_config_crd_is_established() { + let client = test_client().await; + + assert!( + is_crd_established(&client, "sonarrnamingconfigs.devopsarr.io").await, + "SonarrNamingConfig CRD is not established - run 'make install' first" + ); +} + +/// Test creating a SonarrNamingConfig resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_create_sonarr_naming_config() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("naming-test"); + let config = SonarrNamingConfig { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrNamingConfigSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + rename_episodes: Some(true), + replace_illegal_characters: Some(true), + standard_episode_format: Some( + "{Series Title} - S{season:00}E{episode:00} - {Episode Title}".to_string(), + ), + season_folder_format: Some("Season {season}".to_string()), + ..Default::default() + }, + status: None, + }; + + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + let result = api.patch(&name, &patch_params, &Patch::Apply(&config)).await; + + assert!( + result.is_ok(), + "Failed to create SonarrNamingConfig: {:?}", + result.err() + ); + + let retrieved = api.get(&name).await; + assert!( + retrieved.is_ok(), + "Failed to get SonarrNamingConfig: {:?}", + retrieved.err() + ); + + let config = retrieved.unwrap(); + assert_eq!(config.spec.rename_episodes, Some(true)); + assert_eq!( + config.spec.season_folder_format, + Some("Season {season}".to_string()) + ); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test updating a SonarrNamingConfig resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_update_sonarr_naming_config() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("naming-update"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + // Create initial resource + let config = SonarrNamingConfig { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrNamingConfigSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + rename_episodes: Some(false), + ..Default::default() + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&config)) + .await + .expect("Failed to create SonarrNamingConfig"); + + // Update the resource + let updated = SonarrNamingConfig { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrNamingConfigSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + rename_episodes: Some(true), + standard_episode_format: Some("{Series Title} - {Episode Title}".to_string()), + ..Default::default() + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&updated)) + .await + .expect("Failed to update SonarrNamingConfig"); + + let retrieved = api + .get(&name) + .await + .expect("Failed to get SonarrNamingConfig"); + assert_eq!(retrieved.spec.rename_episodes, Some(true)); + assert_eq!( + retrieved.spec.standard_episode_format, + Some("{Series Title} - {Episode Title}".to_string()) + ); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test deleting a SonarrNamingConfig resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_delete_sonarr_naming_config() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("naming-delete"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + let config = SonarrNamingConfig { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrNamingConfigSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + ..Default::default() + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&config)) + .await + .expect("Failed to create SonarrNamingConfig"); + + let delete_result = api.delete(&name, &DeleteParams::default()).await; + assert!( + delete_result.is_ok(), + "Failed to delete SonarrNamingConfig: {:?}", + delete_result.err() + ); + + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + let get_result = api.get(&name).await; + assert!( + get_result.is_err(), + "SonarrNamingConfig should have been deleted" + ); +} diff --git a/tests/integration/quality_definition_crd.rs b/tests/integration/quality_definition_crd.rs new file mode 100644 index 0000000..dc99c1e --- /dev/null +++ b/tests/integration/quality_definition_crd.rs @@ -0,0 +1,276 @@ +//! Integration tests for the SonarrQualityDefinition CRD + +use crate::common::*; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::api::{Api, DeleteParams, Patch, PatchParams}; +use sonarr_operator::crds::quality_definition::{QualityName, SonarrQualityDefinitionSpec}; +use sonarr_operator::crds::{SonarrInstanceRef, SonarrQualityDefinition}; + +/// Test that the SonarrQualityDefinition CRD is installed and established +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_sonarr_quality_definition_crd_is_established() { + let client = test_client().await; + + assert!( + is_crd_established(&client, "sonarrqualitydefinitions.devopsarr.io").await, + "SonarrQualityDefinition CRD is not established - run 'make install' first" + ); +} + +/// Test creating a SonarrQualityDefinition resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_create_sonarr_quality_definition() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("quality-def-test"); + let qd = SonarrQualityDefinition { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrQualityDefinitionSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + quality_name: QualityName::Bluray1080p, + title: Some("Bluray 1080p Custom".to_string()), + min_size: Some(10.0), + max_size: Some(100.0), + preferred_size: Some(50.0), + }, + status: None, + }; + + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + let result = api.patch(&name, &patch_params, &Patch::Apply(&qd)).await; + + assert!( + result.is_ok(), + "Failed to create SonarrQualityDefinition: {:?}", + result.err() + ); + + let retrieved = api.get(&name).await; + assert!( + retrieved.is_ok(), + "Failed to get SonarrQualityDefinition: {:?}", + retrieved.err() + ); + + let qd = retrieved.unwrap(); + assert_eq!(qd.spec.title, Some("Bluray 1080p Custom".to_string())); + assert_eq!(qd.spec.min_size, Some(10.0)); + assert_eq!(qd.spec.max_size, Some(100.0)); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test updating a SonarrQualityDefinition resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_update_sonarr_quality_definition() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("quality-def-update"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + // Create initial resource + let qd = SonarrQualityDefinition { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrQualityDefinitionSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + quality_name: QualityName::Webdl1080p, + title: None, + min_size: Some(5.0), + max_size: Some(50.0), + preferred_size: Some(25.0), + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&qd)) + .await + .expect("Failed to create SonarrQualityDefinition"); + + // Update the resource + let updated = SonarrQualityDefinition { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrQualityDefinitionSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + quality_name: QualityName::Webdl1080p, + title: Some("WEB-DL 1080p Updated".to_string()), + min_size: Some(10.0), + max_size: Some(80.0), + preferred_size: Some(40.0), + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&updated)) + .await + .expect("Failed to update SonarrQualityDefinition"); + + let retrieved = api + .get(&name) + .await + .expect("Failed to get SonarrQualityDefinition"); + assert_eq!( + retrieved.spec.title, + Some("WEB-DL 1080p Updated".to_string()) + ); + assert_eq!(retrieved.spec.min_size, Some(10.0)); + assert_eq!(retrieved.spec.max_size, Some(80.0)); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test deleting a SonarrQualityDefinition resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_delete_sonarr_quality_definition() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("quality-def-delete"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + let qd = SonarrQualityDefinition { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrQualityDefinitionSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + quality_name: QualityName::Hdtv720p, + title: None, + min_size: None, + max_size: None, + preferred_size: None, + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&qd)) + .await + .expect("Failed to create SonarrQualityDefinition"); + + let delete_result = api.delete(&name, &DeleteParams::default()).await; + assert!( + delete_result.is_ok(), + "Failed to delete SonarrQualityDefinition: {:?}", + delete_result.err() + ); + + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + let get_result = api.get(&name).await; + assert!( + get_result.is_err(), + "SonarrQualityDefinition should have been deleted" + ); +} + +/// Test configuring different quality levels +/// Note: Quality definitions already exist in Sonarr, the CRD is used to configure them +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_configure_different_quality_levels() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + // Test configuring different quality levels - each maps to a pre-existing Sonarr quality + let qualities = vec![ + ("sdtv", QualityName::Sdtv, 1.0, 10.0), + ("hdtv720p", QualityName::Hdtv720p, 5.0, 50.0), + ("webdl1080p", QualityName::Webdl1080p, 10.0, 80.0), + ("bluray2160p", QualityName::Bluray2160p, 30.0, 200.0), + ]; + + let mut names = Vec::new(); + + for (quality_slug, quality, min, max) in &qualities { + let name = unique_name(&format!("quality-{}", quality_slug)); + names.push(name.clone()); + + let qd = SonarrQualityDefinition { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrQualityDefinitionSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + quality_name: quality.clone(), + title: None, + min_size: Some(*min), + max_size: Some(*max), + preferred_size: Some((min + max) / 2.0), + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&qd)) + .await + .expect(&format!( + "Failed to create quality definition for {}", + quality_slug + )); + } + + // Verify all were created + for name in &names { + let retrieved = api.get(name).await; + assert!( + retrieved.is_ok(), + "Failed to get quality definition: {}", + name + ); + } + + // Cleanup + for name in &names { + let _ = api.delete(name, &DeleteParams::default()).await; + } +} diff --git a/tests/integration/root_folder_crd.rs b/tests/integration/root_folder_crd.rs new file mode 100644 index 0000000..520c90c --- /dev/null +++ b/tests/integration/root_folder_crd.rs @@ -0,0 +1,247 @@ +//! Integration tests for the SonarrRootFolder CRD + +use crate::common::*; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::api::{Api, DeleteParams, Patch, PatchParams}; +use sonarr_operator::crds::{SonarrInstanceRef, SonarrRootFolder, SonarrRootFolderSpec}; + +/// Test that the SonarrRootFolder CRD is installed and established +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_sonarr_root_folder_crd_is_established() { + let client = test_client().await; + + assert!( + is_crd_established(&client, "sonarrrootfolders.devopsarr.io").await, + "SonarrRootFolder CRD is not established - run 'make install' first" + ); +} + +/// Test creating a SonarrRootFolder resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_create_sonarr_root_folder() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("rootfolder-test"); + let root_folder = SonarrRootFolder { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrRootFolderSpec { + path: "/tv".to_string(), + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + }, + status: None, + }; + + // Create the resource + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + let result = api + .patch(&name, &patch_params, &Patch::Apply(&root_folder)) + .await; + + assert!( + result.is_ok(), + "Failed to create SonarrRootFolder: {:?}", + result.err() + ); + + // Verify it exists + let retrieved = api.get(&name).await; + assert!( + retrieved.is_ok(), + "Failed to get SonarrRootFolder: {:?}", + retrieved.err() + ); + + let rf = retrieved.unwrap(); + assert_eq!(rf.spec.path, "/tv"); + assert_eq!(rf.spec.sonarr_instance_ref.name, "test-sonarr"); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test creating root folders with different paths +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_create_multiple_root_folders() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + let paths = vec!["/tv/shows", "/tv/anime", "/tv/documentaries"]; + let mut names = Vec::new(); + + for (i, path) in paths.iter().enumerate() { + let name = unique_name(&format!("rootfolder-multi-{}", i)); + names.push(name.clone()); + + let root_folder = SonarrRootFolder { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + labels: Some([("test-group".to_string(), "multi-rf-test".to_string())].into()), + ..Default::default() + }, + spec: SonarrRootFolderSpec { + path: path.to_string(), + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&root_folder)) + .await + .expect(&format!("Failed to create root folder for path {}", path)); + } + + // List with label selector + let lp = kube::api::ListParams::default().labels("test-group=multi-rf-test"); + let list = api.list(&lp).await.expect("Failed to list root folders"); + + assert!( + list.items.len() >= 3, + "Expected at least 3 root folders, got {}", + list.items.len() + ); + + // Cleanup + for name in &names { + let _ = api.delete(name, &DeleteParams::default()).await; + } +} + +/// Test updating a SonarrRootFolder path +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_update_sonarr_root_folder() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("rootfolder-update"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + // Create initial resource + let root_folder = SonarrRootFolder { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrRootFolderSpec { + path: "/old/path".to_string(), + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&root_folder)) + .await + .expect("Failed to create SonarrRootFolder"); + + // Update the resource + let updated_rf = SonarrRootFolder { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrRootFolderSpec { + path: "/new/path".to_string(), + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&updated_rf)) + .await + .expect("Failed to update SonarrRootFolder"); + + // Verify the update + let retrieved = api + .get(&name) + .await + .expect("Failed to get SonarrRootFolder"); + assert_eq!(retrieved.spec.path, "/new/path"); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test deleting a SonarrRootFolder resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_delete_sonarr_root_folder() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("rootfolder-delete"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + // Create resource + let root_folder = SonarrRootFolder { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrRootFolderSpec { + path: "/to-be-deleted".to_string(), + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&root_folder)) + .await + .expect("Failed to create SonarrRootFolder"); + + // Delete the resource + let delete_result = api.delete(&name, &DeleteParams::default()).await; + assert!( + delete_result.is_ok(), + "Failed to delete SonarrRootFolder: {:?}", + delete_result.err() + ); + + // Verify it's deleted + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + let get_result = api.get(&name).await; + assert!( + get_result.is_err(), + "SonarrRootFolder should have been deleted" + ); +} diff --git a/tests/integration/sonarr_crd.rs b/tests/integration/sonarr_crd.rs new file mode 100644 index 0000000..f419ad0 --- /dev/null +++ b/tests/integration/sonarr_crd.rs @@ -0,0 +1,389 @@ +//! Integration tests for the Sonarr CRD +//! +//! These tests verify that Sonarr CRDs can be created, read, updated, and deleted +//! in a real Kubernetes cluster. + +use crate::common::*; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::api::{Api, DeleteParams, Patch, PatchParams}; +use sonarr_operator::crds::{ServiceConfig, Sonarr, SonarrSpec, StorageConfig}; + +/// Test that the Sonarr CRD is installed and established +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_sonarr_crd_is_established() { + let client = test_client().await; + + assert!( + is_crd_established(&client, "sonarrs.devopsarr.io").await, + "Sonarr CRD is not established - run 'make install' first" + ); +} + +/// Test creating a minimal Sonarr resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_create_minimal_sonarr() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("sonarr-minimal"); + let sonarr = Sonarr { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrSpec::default(), + status: None, + }; + + // Create the resource + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + let result = api + .patch(&name, &patch_params, &Patch::Apply(&sonarr)) + .await; + + assert!( + result.is_ok(), + "Failed to create Sonarr: {:?}", + result.err() + ); + + // Verify it exists + let retrieved = api.get(&name).await; + assert!( + retrieved.is_ok(), + "Failed to get Sonarr: {:?}", + retrieved.err() + ); + + let sonarr = retrieved.unwrap(); + assert_eq!(sonarr.spec.image, "lscr.io/linuxserver/sonarr:latest"); + assert_eq!(sonarr.spec.replicas, 1); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test creating a Sonarr resource with custom configuration +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_create_sonarr_with_custom_config() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("sonarr-custom"); + let sonarr = Sonarr { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrSpec { + image: "lscr.io/linuxserver/sonarr:develop".to_string(), + replicas: 1, + service: ServiceConfig { + port: 9090, + container_port: 8989, + service_type: "ClusterIP".to_string(), + ..Default::default() + }, + storage: StorageConfig { + size: "5Gi".to_string(), + ..Default::default() + }, + ..Default::default() + }, + status: None, + }; + + // Create the resource + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + let result = api + .patch(&name, &patch_params, &Patch::Apply(&sonarr)) + .await; + + assert!( + result.is_ok(), + "Failed to create Sonarr: {:?}", + result.err() + ); + + // Verify custom values + let retrieved = api.get(&name).await.expect("Failed to get Sonarr"); + assert_eq!(retrieved.spec.image, "lscr.io/linuxserver/sonarr:develop"); + assert_eq!(retrieved.spec.service.port, 9090); + assert_eq!(retrieved.spec.storage.size, "5Gi"); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test updating a Sonarr resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_update_sonarr() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("sonarr-update"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + // Create initial resource + let sonarr = Sonarr { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrSpec { + image: "lscr.io/linuxserver/sonarr:latest".to_string(), + ..Default::default() + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&sonarr)) + .await + .expect("Failed to create Sonarr"); + + // Update the resource + let updated_sonarr = Sonarr { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrSpec { + image: "lscr.io/linuxserver/sonarr:develop".to_string(), + ..Default::default() + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&updated_sonarr)) + .await + .expect("Failed to update Sonarr"); + + // Verify the update + let retrieved = api.get(&name).await.expect("Failed to get Sonarr"); + assert_eq!(retrieved.spec.image, "lscr.io/linuxserver/sonarr:develop"); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test deleting a Sonarr resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_delete_sonarr() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("sonarr-delete"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + // Create resource + let sonarr = Sonarr { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrSpec::default(), + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&sonarr)) + .await + .expect("Failed to create Sonarr"); + + // Delete the resource + let delete_result = api.delete(&name, &DeleteParams::default()).await; + assert!( + delete_result.is_ok(), + "Failed to delete Sonarr: {:?}", + delete_result.err() + ); + + // Verify it's deleted (may take a moment) + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + let get_result = api.get(&name).await; + assert!(get_result.is_err(), "Sonarr should have been deleted"); +} + +/// Test listing Sonarr resources +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_list_sonarrs() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + // Create a few resources + let names: Vec = (0..3) + .map(|i| unique_name(&format!("sonarr-list-{}", i))) + .collect(); + + for name in &names { + let sonarr = Sonarr { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + labels: Some([("test-group".to_string(), "list-test".to_string())].into()), + ..Default::default() + }, + spec: SonarrSpec::default(), + status: None, + }; + api.patch(name, &patch_params, &Patch::Apply(&sonarr)) + .await + .expect("Failed to create Sonarr"); + } + + // List with label selector + let lp = kube::api::ListParams::default().labels("test-group=list-test"); + let list = api.list(&lp).await.expect("Failed to list Sonarrs"); + + assert!( + list.items.len() >= 3, + "Expected at least 3 Sonarrs, got {}", + list.items.len() + ); + + // Cleanup + for name in &names { + let _ = api.delete(name, &DeleteParams::default()).await; + } +} + +/// Test that validation rejects invalid Sonarr specs +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_sonarr_with_ingress_config() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("sonarr-ingress"); + let sonarr = Sonarr { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrSpec { + ingress: Some(sonarr_operator::crds::sonarr::IngressConfig { + enabled: true, + host: "sonarr.example.com".to_string(), + path: "/".to_string(), + path_type: "Prefix".to_string(), + ingress_class_name: Some("nginx".to_string()), + tls: None, + annotations: Default::default(), + }), + ..Default::default() + }, + status: None, + }; + + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + let result = api + .patch(&name, &patch_params, &Patch::Apply(&sonarr)) + .await; + + assert!( + result.is_ok(), + "Failed to create Sonarr with ingress config: {:?}", + result.err() + ); + + // Verify ingress config + let retrieved = api.get(&name).await.expect("Failed to get Sonarr"); + let ingress = retrieved.spec.ingress.expect("Ingress config should exist"); + assert!(ingress.enabled); + assert_eq!(ingress.host, "sonarr.example.com"); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test Sonarr with HTTPRoute configuration for Gateway API +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_sonarr_with_http_route_config() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("sonarr-httproute"); + let sonarr = Sonarr { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrSpec { + http_route: Some(sonarr_operator::crds::sonarr::HTTPRouteConfig { + enabled: true, + gateway_ref: sonarr_operator::crds::sonarr::GatewayRef { + name: "my-gateway".to_string(), + namespace: Some("gateway-system".to_string()), + section_name: None, + }, + hostnames: vec!["sonarr.example.com".to_string()], + path: "/".to_string(), + path_type: "PathPrefix".to_string(), + labels: Default::default(), + annotations: Default::default(), + }), + ..Default::default() + }, + status: None, + }; + + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + let result = api + .patch(&name, &patch_params, &Patch::Apply(&sonarr)) + .await; + + assert!( + result.is_ok(), + "Failed to create Sonarr with HTTPRoute config: {:?}", + result.err() + ); + + // Verify HTTPRoute config + let retrieved = api.get(&name).await.expect("Failed to get Sonarr"); + let http_route = retrieved + .spec + .http_route + .expect("HTTPRoute config should exist"); + assert!(http_route.enabled); + assert_eq!(http_route.gateway_ref.name, "my-gateway"); + assert_eq!(http_route.hostnames, vec!["sonarr.example.com".to_string()]); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} diff --git a/tests/integration/tag_crd.rs b/tests/integration/tag_crd.rs new file mode 100644 index 0000000..dca5f48 --- /dev/null +++ b/tests/integration/tag_crd.rs @@ -0,0 +1,239 @@ +//! Integration tests for the SonarrTag CRD + +use crate::common::*; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::api::{Api, DeleteParams, Patch, PatchParams}; +use sonarr_operator::crds::{SonarrInstanceRef, SonarrTag, SonarrTagSpec}; + +/// Test that the SonarrTag CRD is installed and established +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_sonarr_tag_crd_is_established() { + let client = test_client().await; + + assert!( + is_crd_established(&client, "sonarrtags.devopsarr.io").await, + "SonarrTag CRD is not established - run 'make install' first" + ); +} + +/// Test creating a SonarrTag resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_create_sonarr_tag() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("tag-test"); + let tag = SonarrTag { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrTagSpec { + label: "integration-test".to_string(), + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + }, + status: None, + }; + + // Create the resource + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + let result = api.patch(&name, &patch_params, &Patch::Apply(&tag)).await; + + assert!( + result.is_ok(), + "Failed to create SonarrTag: {:?}", + result.err() + ); + + // Verify it exists + let retrieved = api.get(&name).await; + assert!( + retrieved.is_ok(), + "Failed to get SonarrTag: {:?}", + retrieved.err() + ); + + let tag = retrieved.unwrap(); + assert_eq!(tag.spec.label, "integration-test"); + assert_eq!(tag.spec.sonarr_instance_ref.name, "test-sonarr"); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test updating a SonarrTag resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_update_sonarr_tag() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("tag-update"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + // Create initial resource + let tag = SonarrTag { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrTagSpec { + label: "original-label".to_string(), + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&tag)) + .await + .expect("Failed to create SonarrTag"); + + // Update the resource + let updated_tag = SonarrTag { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrTagSpec { + label: "updated-label".to_string(), + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&updated_tag)) + .await + .expect("Failed to update SonarrTag"); + + // Verify the update + let retrieved = api.get(&name).await.expect("Failed to get SonarrTag"); + assert_eq!(retrieved.spec.label, "updated-label"); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test deleting a SonarrTag resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_delete_sonarr_tag() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("tag-delete"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + // Create resource + let tag = SonarrTag { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrTagSpec { + label: "to-be-deleted".to_string(), + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&tag)) + .await + .expect("Failed to create SonarrTag"); + + // Delete the resource + let delete_result = api.delete(&name, &DeleteParams::default()).await; + assert!( + delete_result.is_ok(), + "Failed to delete SonarrTag: {:?}", + delete_result.err() + ); + + // Verify it's deleted + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + let get_result = api.get(&name).await; + assert!(get_result.is_err(), "SonarrTag should have been deleted"); +} + +/// Test creating multiple tags +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_create_multiple_tags() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + let tags = vec!["anime", "documentary", "kids"]; + let mut names = Vec::new(); + + for tag_label in &tags { + let name = unique_name(&format!("tag-multi-{}", tag_label)); + names.push(name.clone()); + + let tag = SonarrTag { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + labels: Some([("test-group".to_string(), "multi-tag-test".to_string())].into()), + ..Default::default() + }, + spec: SonarrTagSpec { + label: tag_label.to_string(), + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&tag)) + .await + .expect(&format!("Failed to create tag {}", tag_label)); + } + + // List with label selector + let lp = kube::api::ListParams::default().labels("test-group=multi-tag-test"); + let list = api.list(&lp).await.expect("Failed to list tags"); + + assert!( + list.items.len() >= 3, + "Expected at least 3 tags, got {}", + list.items.len() + ); + + // Cleanup + for name in &names { + let _ = api.delete(name, &DeleteParams::default()).await; + } +} From f960349159ac21ff943ca336beae821c009d2766 Mon Sep 17 00:00:00 2001 From: Fuochi Date: Sat, 28 Feb 2026 10:14:05 +0100 Subject: [PATCH 06/22] chore: claudio cleaning --- .github/workflows/ci.yml | 575 ++++++++++-------- Makefile | 76 +-- README.md | 120 +++- deploy/deployment.yaml | 2 +- docs/TESTING.md | 226 +++---- tests/e2e/common.rs | 36 ++ tests/e2e/fixtures/01-sonarr-instance.yaml | 7 - tests/e2e/fixtures/02-tags.yaml | 30 - tests/e2e/fixtures/03-root-folders.yaml | 20 - tests/e2e/fixtures/cleanup.sh | 27 - tests/e2e/fixtures/setup.sh | 102 ---- tests/e2e/fixtures/sonarr-deployment.yaml | 114 ---- tests/e2e/fixtures/sonarr-instance.yaml | 31 +- .../download_client_config_lifecycle.rs | 224 +++++++ .../e2e/scenarios/indexer_config_lifecycle.rs | 225 +++++++ tests/e2e/scenarios/mod.rs | 12 +- tests/e2e/sonarr_client.rs | 58 +- 17 files changed, 1079 insertions(+), 806 deletions(-) delete mode 100644 tests/e2e/fixtures/01-sonarr-instance.yaml delete mode 100644 tests/e2e/fixtures/02-tags.yaml delete mode 100644 tests/e2e/fixtures/03-root-folders.yaml delete mode 100644 tests/e2e/fixtures/cleanup.sh delete mode 100644 tests/e2e/fixtures/setup.sh delete mode 100644 tests/e2e/fixtures/sonarr-deployment.yaml create mode 100644 tests/e2e/scenarios/download_client_config_lifecycle.rs create mode 100644 tests/e2e/scenarios/indexer_config_lifecycle.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ba845a..741146c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,262 +1,327 @@ name: CI on: - push: - branches: [main] - pull_request: - branches: [main] + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true env: - CARGO_TERM_COLOR: always - RUST_BACKTRACE: 1 + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} jobs: - # ============================================ - # Lint and Format Check - # ============================================ - lint: - name: Lint & Format - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt, clippy - - uses: Swatinem/rust-cache@v2 - - name: Check formatting - run: cargo fmt -- --check - - name: Clippy - run: cargo clippy -- -D warnings - - # ============================================ - # Unit Tests - # ============================================ - test: - name: Unit Tests - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - - name: Run unit tests - run: cargo test --lib - - # ============================================ - # Build - # ============================================ - build: - name: Build - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - - name: Build - run: cargo build --release - - name: Generate CRDs - run: cargo run --bin crdgen -- --split deploy/crds - - name: Upload CRDs artifact - uses: actions/upload-artifact@v4 - with: - name: crds - path: deploy/crds/ - - # ============================================ - # Generate CRD Documentation - # ============================================ - docs: - name: Generate Docs - runs-on: ubuntu-latest - needs: [build] - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - - uses: actions/setup-go@v5 - with: - go-version: '1.22' - - - name: Install crdoc - run: go install fybrik.io/crdoc@latest - - - name: Generate CRDs - run: cargo run --bin crdgen -- --split deploy/crds - - - name: Generate CRD documentation - run: | - mkdir -p docs/api - crdoc --resources deploy/crds --output docs/api/crd-reference.md - - - name: Upload documentation artifact - uses: actions/upload-artifact@v4 - with: - name: docs - path: docs/api/ - - # ============================================ - # Integration Tests (CRD validation) - # ============================================ - integration-test: - name: Integration Tests (K8s ${{ matrix.k8s }}) - runs-on: ubuntu-latest - needs: [lint, test, build] - strategy: - fail-fast: false - matrix: - k8s: [v1.28, v1.29, latest] - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - - - name: Set up k3d cluster - uses: nolar/setup-k3d-k3s@v1 - with: - version: ${{ matrix.k8s }} - k3d-name: sonarr-operator-test - k3d-args: '--no-lb --no-rollback --k3s-arg --disable=traefik,servicelb,metrics-server@server:*' - - - name: Generate and install CRDs - run: | - cargo run --bin crdgen -- --split deploy/crds - kubectl apply -f deploy/crds/ - - - name: Wait for CRDs - run: | - kubectl wait --for=condition=established crd/sonarrs.devopsarr.io --timeout=60s - kubectl wait --for=condition=established crd/sonarrtags.devopsarr.io --timeout=60s - kubectl wait --for=condition=established crd/sonarrrootfolders.devopsarr.io --timeout=60s - kubectl wait --for=condition=established crd/sonarrindexers.devopsarr.io --timeout=60s - kubectl wait --for=condition=established crd/sonarrdownloadclients.devopsarr.io --timeout=60s - kubectl wait --for=condition=established crd/sonarrnotifications.devopsarr.io --timeout=60s - kubectl wait --for=condition=established crd/sonarrqualityprofiles.devopsarr.io --timeout=60s - kubectl wait --for=condition=established crd/sonarrseries.devopsarr.io --timeout=60s - - - name: Run integration tests - run: cargo test --test integration -- --ignored --test-threads=1 - - - name: Cleanup - if: always() - run: kubectl delete namespace sonarr-operator-test --ignore-not-found - - # ============================================ - # E2E Tests (Full reconciliation with Sonarr) - # ============================================ - e2e-test: - name: E2E Tests - runs-on: ubuntu-latest - needs: [integration-test] - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - - - name: Set up k3d cluster - uses: nolar/setup-k3d-k3s@v1 - with: - version: latest - k3d-name: sonarr-operator-e2e - k3d-args: '--no-lb --no-rollback --k3s-arg --disable=traefik,servicelb,metrics-server@server:*' - - - name: Generate and install CRDs - run: | - cargo run --bin crdgen -- --split deploy/crds - kubectl apply -f deploy/crds/ - - - name: Deploy Sonarr - run: | - kubectl apply -f tests/e2e/fixtures/sonarr-deployment.yaml - echo "Waiting for Sonarr deployment..." - kubectl wait --for=condition=available deployment/sonarr -n default --timeout=300s - echo "Waiting for Sonarr pod..." - kubectl wait --for=condition=ready pod -l app=sonarr -n default --timeout=300s - echo "Giving Sonarr time to initialize..." - sleep 60 - - - name: Extract Sonarr API key - id: sonarr-key - run: | - SONARR_POD=$(kubectl get pod -l app=sonarr -n default -o jsonpath='{.items[0].metadata.name}') - for i in {1..20}; do - API_KEY=$(kubectl exec "${SONARR_POD}" -n default -- cat /config/config.xml 2>/dev/null | grep -oP '(?<=)[^<]+' || true) - if [ -n "${API_KEY}" ]; then - echo "API key extracted" - echo "api_key=${API_KEY}" >> $GITHUB_OUTPUT - break - fi - echo "Waiting for API key... (attempt $i/20)" - sleep 10 - done - if [ -z "${API_KEY}" ]; then - echo "Failed to extract API key" - kubectl logs "${SONARR_POD}" -n default --tail=100 - exit 1 - fi - - - name: Create Sonarr instance - run: | - kubectl create secret generic sonarr-api-key \ - --from-literal=api-key="${{ steps.sonarr-key.outputs.api_key }}" \ - -n default - kubectl apply -f tests/e2e/fixtures/sonarr-instance.yaml - - - name: Start operator in background - run: | - cargo build --release - RUST_LOG=info ./target/release/sonarr-operator & - echo $! > /tmp/operator.pid - sleep 10 - - - name: Setup port-forward - run: | - kubectl port-forward svc/sonarr 8989:8989 -n default & - echo $! > /tmp/port-forward.pid - sleep 5 - - - name: Run E2E tests - env: - SONARR_API_KEY: ${{ steps.sonarr-key.outputs.api_key }} - SONARR_URL: http://localhost:8989 - run: cargo test --test e2e -- --ignored --test-threads=1 --nocapture - - - name: Cleanup - if: always() - run: | - if [ -f /tmp/operator.pid ]; then - kill $(cat /tmp/operator.pid) 2>/dev/null || true - fi - if [ -f /tmp/port-forward.pid ]; then - kill $(cat /tmp/port-forward.pid) 2>/dev/null || true - fi - - - name: Collect logs on failure - if: failure() - run: | - echo "=== Operator logs ===" - kubectl logs -l app=sonarr-operator -n sonarr-operator-system --tail=100 2>/dev/null || echo "No operator logs" - echo "=== Sonarr logs ===" - kubectl logs -l app=sonarr -n default --tail=100 2>/dev/null || echo "No Sonarr logs" - echo "=== All resources ===" - kubectl get all -A - echo "=== CRDs ===" - kubectl get sonarrs,sonarrtags,sonarrrootfolders -A 2>/dev/null || true - - # ============================================ - # Docker Build - # ============================================ - docker: - name: Docker Build - runs-on: ubuntu-latest - needs: [lint, test, build] - steps: - - uses: actions/checkout@v4 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - name: Build Docker image - uses: docker/build-push-action@v5 - with: - context: . - push: false - tags: sonarr-operator:${{ github.sha }} - cache-from: type=gha - cache-to: type=gha,mode=max + # ============================================ + # Lint and Format Check + # ============================================ + lint: + name: Lint & Format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + - name: Check formatting + run: cargo fmt -- --check + - name: Clippy + run: cargo clippy -- -D warnings + + # ============================================ + # Unit Tests + # ============================================ + test: + name: Unit Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Run unit tests + run: cargo test --lib + + # ============================================ + # Generate CRDs (shared artifact) + # ============================================ + generate-crds: + name: Generate CRDs + runs-on: ubuntu-latest + needs: [lint, test] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Generate CRDs + run: cargo run --bin crdgen -- --split deploy/crds + - name: Upload CRDs artifact + uses: actions/upload-artifact@v4 + with: + name: crds + path: deploy/crds/ + + # ============================================ + # Generate CRD Documentation + # ============================================ + docs: + name: Generate Docs + runs-on: ubuntu-latest + needs: [generate-crds] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.22' + - name: Download CRDs artifact + uses: actions/download-artifact@v4 + with: + name: crds + path: deploy/crds/ + - name: Install crdoc + run: go install fybrik.io/crdoc@latest + - name: Generate CRD documentation + run: | + mkdir -p docs/api + crdoc --resources deploy/crds --output docs/api/crd-reference.md + - name: Upload documentation artifact + uses: actions/upload-artifact@v4 + with: + name: docs + path: docs/api/ + + # ============================================ + # Build and Push Docker Image + # ============================================ + build-image: + name: Build & Push Image + runs-on: ubuntu-latest + needs: [lint, test] + permissions: + contents: read + packages: write + outputs: + image-tag: ${{ steps.meta.outputs.tags }} + short-sha: ${{ steps.short-sha.outputs.sha }} + steps: + - uses: actions/checkout@v4 + + - name: Get short SHA + id: short-sha + run: echo "sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=sha,prefix=,format=short + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + # ============================================ + # Integration Tests (CRD validation) + # ============================================ + integration-test: + name: Integration Tests (K8s ${{ matrix.k8s }}) + runs-on: ubuntu-latest + needs: [generate-crds] + strategy: + fail-fast: false + matrix: + k8s: [v1.28, v1.29, latest] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + + - name: Download CRDs artifact + uses: actions/download-artifact@v4 + with: + name: crds + path: deploy/crds/ + + - name: Set up k3d cluster + uses: nolar/setup-k3d-k3s@v1 + with: + version: ${{ matrix.k8s }} + k3d-name: sonarr-operator-test + k3d-args: '--no-lb --no-rollback --k3s-arg --disable=traefik,servicelb,metrics-server@server:*' + + - name: Install CRDs + run: kubectl apply -f deploy/crds/ + + - name: Wait for CRDs + run: | + for crd in sonarrs sonarrtags sonarrrootfolders sonarrindexers \ + sonarrdownloadclients sonarrnotifications \ + sonarrqualityprofiles sonarrseries sonarrautotags \ + sonarrcustomformats sonarrdelayprofiles \ + sonarrdownloadclientconfigs sonarrimportlists \ + sonarrindexerconfigs sonarrlanguageprofiles \ + sonarrmediamanagementconfigs sonarrmetadatas \ + sonarrnamingconfigs sonarrqualitydefinitions; do + kubectl wait --for=condition=established "crd/${crd}.devopsarr.io" --timeout=60s + done + + - name: Run integration tests + run: cargo test --test integration -- --ignored --test-threads=1 + + - name: Cleanup + if: always() + run: kubectl delete namespace sonarr-operator-test --ignore-not-found + + # ============================================ + # E2E Tests (Full reconciliation with Sonarr) + # ============================================ + e2e-test: + name: E2E Tests + runs-on: ubuntu-latest + timeout-minutes: 20 + needs: [generate-crds, build-image] + permissions: + contents: read + packages: read + env: + API_KEY: ${{ github.run_id }}${{ github.sha }}${{ github.run_attempt }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + + - name: Download CRDs artifact + uses: actions/download-artifact@v4 + with: + name: crds + path: deploy/crds/ + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Pull operator image + run: docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.build-image.outputs.short-sha }} + + - name: Create k3d cluster with port mapping + run: | + k3d cluster create sonarr-e2e \ + --port 8989:30989@server:0 \ + --no-lb \ + --k3s-arg '--disable=traefik,servicelb,metrics-server@server:*' + + - name: Import operator image into k3d + run: | + k3d image import \ + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.build-image.outputs.short-sha }} \ + --cluster sonarr-e2e + + - name: Install CRDs + run: | + kubectl apply -f deploy/crds/ + for crd in sonarrs sonarrtags sonarrrootfolders sonarrindexers \ + sonarrdownloadclients sonarrnotifications \ + sonarrqualityprofiles sonarrseries sonarrautotags \ + sonarrcustomformats sonarrdelayprofiles \ + sonarrdownloadclientconfigs sonarrimportlists \ + sonarrindexerconfigs sonarrlanguageprofiles \ + sonarrmediamanagementconfigs sonarrmetadatas \ + sonarrnamingconfigs sonarrqualitydefinitions; do + kubectl wait --for=condition=established "crd/${crd}.devopsarr.io" --timeout=60s + done + + - name: Deploy operator + run: | + kubectl apply -f deploy/namespace.yaml + kubectl apply -f deploy/rbac.yaml + # Patch the deployment image to the one we just built + sed 's|image: .*|image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.build-image.outputs.short-sha }}|' \ + deploy/deployment.yaml | kubectl apply -f - + kubectl wait --for=condition=available deployment/sonarr-operator \ + -n sonarr-operator-system --timeout=120s + echo "Operator is running" + + - name: Create API key secret + run: | + kubectl create secret generic sonarr-api-key \ + --from-literal=api-key="${API_KEY}" \ + -n default + + - name: Apply Sonarr CR + run: | + kubectl apply -f tests/e2e/fixtures/sonarr-instance.yaml + echo "Waiting for Sonarr CR to be ready..." + kubectl wait --for=jsonpath='{.status.conditions[?(@.type=="Ready")].status}'=True \ + sonarr/sonarr -n default --timeout=300s + echo "Sonarr instance is ready" + + - name: Wait for Sonarr API to be available + run: | + echo "Waiting for Sonarr API on localhost:8989..." + for i in $(seq 1 60); do + if curl -sf -H "X-Api-Key: ${API_KEY}" \ + http://localhost:8989/api/v3/system/status > /dev/null 2>&1; then + echo "Sonarr API is ready" + exit 0 + fi + echo "Attempt $i/60 - waiting..." + sleep 5 + done + echo "Sonarr API not reachable" + kubectl get pods -A + kubectl logs -l app.kubernetes.io/name=sonarr -n default --tail=50 || true + exit 1 + + - name: Run E2E tests + env: + SONARR_API_KEY: ${{ env.API_KEY }} + SONARR_URL: http://localhost:8989 + run: cargo test --test e2e -- --ignored --test-threads=1 --nocapture + + - name: Collect logs on failure + if: failure() + run: | + echo "=== Operator logs ===" + kubectl logs deployment/sonarr-operator -n sonarr-operator-system --tail=200 || echo "No operator logs" + echo "" + echo "=== Sonarr pod logs ===" + kubectl logs -l app.kubernetes.io/name=sonarr -n default --all-containers --tail=100 || echo "No Sonarr logs" + echo "" + echo "=== Sonarr CR status ===" + kubectl get sonarr -A -o yaml 2>/dev/null || true + echo "" + echo "=== All pods ===" + kubectl get pods -A -o wide + echo "" + echo "=== All devopsarr resources ===" + kubectl get sonarrs,sonarrtags,sonarrrootfolders,sonarrqualityprofiles -A 2>/dev/null || true + echo "" + echo "=== Events ===" + kubectl get events -A --sort-by='.lastTimestamp' | tail -40 diff --git a/Makefile b/Makefile index faa85bf..3f9cd5a 100644 --- a/Makefile +++ b/Makefile @@ -185,36 +185,37 @@ kind-load: docker ## Load Docker image into Kind cluster kind load docker-image $(IMAGE_NAME):$(IMAGE_TAG) --name sonarr-operator-test ##@ End-to-End Testing +# +# The E2E flow is: +# 1. Create a k3d/Kind cluster and install CRDs +# 2. Run the operator (locally or in-cluster) +# 3. Create an API key Secret + apply the Sonarr CR +# 4. The operator creates the Sonarr Deployment, Service, PVC +# 5. Run E2E tests against the live Sonarr instance +# +# Required env vars: SONARR_API_KEY, SONARR_URL + +E2E_API_KEY ?= test-e2e-api-key-12345 -.PHONY: e2e-setup -e2e-setup: kind-create install ## Setup E2E test environment with Sonarr - @echo "Setting up E2E test environment..." - chmod +x tests/e2e/fixtures/setup.sh - ./tests/e2e/fixtures/setup.sh - @echo "E2E environment ready" - -.PHONY: e2e-sonarr -e2e-sonarr: ## Deploy only Sonarr for E2E tests (use after kind-create install) - @echo "Deploying Sonarr..." - kubectl apply -f tests/e2e/fixtures/sonarr-deployment.yaml - kubectl wait --for=condition=available deployment/sonarr -n default --timeout=300s - kubectl wait --for=condition=ready pod -l app=sonarr -n default --timeout=300s - @echo "Waiting for Sonarr to initialize..." - sleep 60 - @echo "Extracting API key..." - @SONARR_POD=$$(kubectl get pod -l app=sonarr -n default -o jsonpath='{.items[0].metadata.name}'); \ - API_KEY=$$(kubectl exec $$SONARR_POD -n default -- cat /config/config.xml 2>/dev/null | grep -oP '(?<=)[^<]+'); \ - echo "API Key: $$API_KEY"; \ - kubectl create secret generic sonarr-api-key --from-literal=api-key="$$API_KEY" -n default --dry-run=client -o yaml | kubectl apply -f - +.PHONY: e2e-deploy-sonarr +e2e-deploy-sonarr: install ## Create API key secret + apply Sonarr CR (operator must be running) + @echo "Creating API key secret..." + kubectl create secret generic sonarr-api-key \ + --from-literal=api-key="$(E2E_API_KEY)" \ + -n default --dry-run=client -o yaml | kubectl apply -f - + @echo "Applying Sonarr CR..." kubectl apply -f tests/e2e/fixtures/sonarr-instance.yaml - @echo "Sonarr deployed and configured" + @echo "Waiting for Sonarr to be ready..." + kubectl wait --for=jsonpath='{.status.conditions[?(@.type=="Ready")].status}'=True \ + sonarr/sonarr -n default --timeout=300s + @echo "Sonarr instance is ready" .PHONY: e2e -e2e: ## Run E2E tests (requires e2e-setup or e2e-sonarr first, operator running) +e2e: ## Run E2E tests (requires operator + Sonarr running) @echo "Running E2E tests..." @if [ -z "$$SONARR_API_KEY" ]; then \ echo "ERROR: SONARR_API_KEY environment variable not set"; \ - echo "Run: export SONARR_API_KEY=\$$(cat tests/e2e/fixtures/.sonarr-api-key)"; \ + echo "Run: export SONARR_API_KEY=$(E2E_API_KEY)"; \ exit 1; \ fi @if [ -z "$$SONARR_URL" ]; then \ @@ -231,33 +232,12 @@ e2e-verbose: ## Run E2E tests with verbose output fi RUST_LOG=debug cargo test --test e2e -- --ignored --test-threads=1 --nocapture -.PHONY: e2e-port-forward -e2e-port-forward: ## Start port-forward to Sonarr (run in separate terminal) - @echo "Port-forwarding to Sonarr on localhost:8989..." - @echo "Press Ctrl+C to stop" - kubectl port-forward svc/sonarr 8989:8989 -n default - .PHONY: e2e-cleanup e2e-cleanup: ## Cleanup E2E test resources - chmod +x tests/e2e/fixtures/cleanup.sh - ./tests/e2e/fixtures/cleanup.sh - -.PHONY: e2e-full -e2e-full: e2e-setup ## Run full E2E test suite (setup + tests + cleanup) - @echo "Starting operator in background..." - RUST_LOG=info cargo run & - sleep 10 - @echo "Setting up port-forward..." - kubectl port-forward svc/sonarr 8989:8989 -n default & - sleep 5 - @echo "Running E2E tests..." - export SONARR_API_KEY=$$(cat tests/e2e/fixtures/.sonarr-api-key); \ - export SONARR_URL="http://localhost:8989"; \ - cargo test --test e2e -- --ignored --test-threads=1 || true - @echo "Cleaning up..." - pkill -f "cargo run" || true - pkill -f "port-forward" || true - $(MAKE) e2e-cleanup + kubectl delete -f tests/e2e/fixtures/sonarr-instance.yaml --ignore-not-found + kubectl delete secret sonarr-api-key -n default --ignore-not-found + kubectl delete namespace sonarr-e2e-test --ignore-not-found + @echo "E2E resources cleaned up" ##@ Complete Workflows diff --git a/README.md b/README.md index 79a0093..63c9f78 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A Kubernetes operator for [Sonarr](https://sonarr.tv/) written in Rust using [kube-rs](https://kube.rs/). -This operator allows you to manage Sonarr resources (tags, root folders, quality profiles, indexers, download clients, notifications, and series) declaratively through Kubernetes Custom Resources. +This operator allows you to manage Sonarr resources declaratively through Kubernetes Custom Resources, enabling GitOps workflows for TV series management. ## Features @@ -11,19 +11,48 @@ This operator allows you to manage Sonarr resources (tags, root folders, quality - **Multi-Instance Support**: Manage multiple Sonarr instances from a single operator - **Automatic Synchronization**: Resources are continuously reconciled with Sonarr - **Finalizers**: Clean up resources in Sonarr when Kubernetes resources are deleted +- **19 CRDs**: Comprehensive coverage of Sonarr configuration options ## Supported Resources -| CRD | Description | -|-----|-------------| -| `Sonarr` | Connection to a Sonarr instance | -| `SonarrTag` | Manage tags for organizing series | -| `SonarrRootFolder` | Configure root folders for media storage | -| `SonarrQualityProfile` | Define quality profiles | -| `SonarrIndexer` | Configure indexers for searching | -| `SonarrDownloadClient` | Set up download clients (SABnzbd, qBittorrent, etc.) | -| `SonarrNotification` | Configure notifications (Discord, Telegram, etc.) | -| `SonarrSeries` | Add and manage TV series | +The operator manages **19 CRDs** in the `devopsarr.io/v1alpha1` API group: + +### Main Instance +- **[Sonarr](docs/api/crd-reference.md#sonarr)** - The Sonarr server instance configuration + +### Content +- **[SonarrSeries](docs/api/crd-reference.md#sonarrseries)** - TV series management + +### Profiles +- **[SonarrQualityProfile](docs/api/crd-reference.md#sonarrqualityprofile)** - Quality profiles for downloads +- **[SonarrLanguageProfile](docs/api/crd-reference.md#sonarrlanguageprofile)** - Language preferences +- **[SonarrDelayProfile](docs/api/crd-reference.md#sonarrdelayprofile)** - Delay settings for releases + +### Integrations +- **[SonarrDownloadClient](docs/api/crd-reference.md#sonarrdownloadclient)** - Download clients (qBittorrent, SABnzbd, etc.) +- **[SonarrIndexer](docs/api/crd-reference.md#sonarrindexer)** - Indexers for searching torrents/usenet +- **[SonarrNotification](docs/api/crd-reference.md#sonarrnotification)** - Notifications (Discord, Telegram, etc.) +- **[SonarrImportList](docs/api/crd-reference.md#sonarrimportlist)** - Import lists for automatic series discovery + +### Organization +- **[SonarrTag](docs/api/crd-reference.md#sonarrtag)** - Tags for organizing series +- **[SonarrAutoTag](docs/api/crd-reference.md#sonarrautotag)** - Automatic tagging rules +- **[SonarrRootFolder](docs/api/crd-reference.md#sonarrrootfolder)** - Root folders for media storage + +### Quality +- **[SonarrQualityDefinition](docs/api/crd-reference.md#sonarrqualitydefinition)** - Quality definitions +- **[SonarrCustomFormat](docs/api/crd-reference.md#sonarrcustomformat)** - Custom format specifications + +### Metadata +- **[SonarrMetadata](docs/api/crd-reference.md#sonarrmetadata)** - Metadata providers + +### Config (Singletons per instance) +- **[SonarrMediaManagementConfig](docs/api/crd-reference.md#sonarrmediamanagementconfig)** - File management settings +- **[SonarrNamingConfig](docs/api/crd-reference.md#sonarrnamingconfig)** - Episode/series naming patterns +- **[SonarrIndexerConfig](docs/api/crd-reference.md#sonarrindexerconfig)** - Global indexer settings +- **[SonarrDownloadClientConfig](docs/api/crd-reference.md#sonarrdownloadclientconfig)** - Global download client settings + +For detailed API specifications, see the [CRD Reference](docs/api/crd-reference.md). ## Quick Start @@ -232,24 +261,63 @@ See [docs/TESTING.md](docs/TESTING.md) for comprehensive testing instructions, i ## Architecture +The operator implements the [Kubernetes Operator pattern](https://kubernetes.io/docs/concepts/extend-kubernetes/operator/) to declaratively manage Sonarr configuration. It runs as a Deployment in the cluster and watches 19 CRDs in the `devopsarr.io/v1alpha1` API group. + +### How It Works + ``` + Kubernetes Cluster ┌─────────────────────────────────────────────────────────────┐ -│ Kubernetes Cluster │ -│ ┌─────────────────┐ ┌─────────────────┐ │ -│ │ Sonarr CRDs │ │ Sonarr │ │ -│ │ - Sonarr │────▶│ Operator │ │ -│ │ - SonarrTag │ │ │ │ -│ │ - SonarrSeries │ └────────┬────────┘ │ -│ │ - ... │ │ │ -│ └─────────────────┘ │ API Calls │ -└───────────────────────────────────┼─────────────────────────┘ - │ - ▼ - ┌─────────────────┐ - │ Sonarr Server │ - │ (External) │ - └─────────────────┘ +│ │ +│ ┌──────────────┐ watches ┌───────────────────────────┐ │ +│ │ Sonarr │◄───────────│ Sonarr Operator │ │ +│ │ CRDs (19) │ │ (Rust / kube-rs) │ │ +│ │ │ │ │ │ +│ │ devopsarr.io │ status │ ┌───────────────────────┐ │ │ +│ │ /v1alpha1 │◄───────────│ │ 19 Controllers │ │ │ +│ └──────────────┘ updates │ │ (one per CRD type) │ │ │ +│ │ └───────────┬───────────┘ │ │ +│ ┌──────────────┐ └─────────────┼─────────────┘ │ +│ │ K8s Secrets │ │ │ +│ │ (API keys) │──────────────────────────┤ │ +│ └──────────────┘ credentials │ │ +│ │ HTTP REST API │ +│ │ (v3/v4) │ +│ ▼ │ +│ ┌───────────────────────────┐ │ +│ │ Sonarr Instance(s) │ │ +│ │ (Pods / Services) │ │ +│ └───────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Resource Hierarchy + +All sub-resource CRDs reference a parent **Sonarr** instance via `sonarrInstanceRef`: + ``` +Sonarr (instance connection: URL + API key) +├── Content: SonarrSeries +├── Profiles: SonarrQualityProfile, SonarrLanguageProfile, SonarrDelayProfile +├── Integrations: SonarrDownloadClient, SonarrIndexer, SonarrNotification, SonarrImportList +├── Organization: SonarrTag, SonarrAutoTag, SonarrRootFolder +├── Quality: SonarrQualityDefinition, SonarrCustomFormat +├── Metadata: SonarrMetadata +└── Config: SonarrMediaManagementConfig, SonarrNamingConfig, + SonarrIndexerConfig, SonarrDownloadClientConfig +``` + +### Reconciliation Loop + +Each controller runs an independent reconciliation loop: + +1. **Watch** — Detect create/update/delete events on the CRD +2. **Resolve** — Look up the `SonarrInstanceRef` to get URL and API key from the Sonarr CR and its Secret +3. **Apply** — Call the Sonarr REST API to create or update the resource +4. **Status** — Write the Sonarr resource ID and a `Ready` condition back to the CRD status +5. **Finalize** — On deletion, remove the resource from Sonarr before allowing the CR to be garbage-collected +6. **Requeue** — Re-reconcile every 5 minutes to catch out-of-band changes (errors requeue after 60 seconds) ## Configuration diff --git a/deploy/deployment.yaml b/deploy/deployment.yaml index dc8319c..8951ca1 100644 --- a/deploy/deployment.yaml +++ b/deploy/deployment.yaml @@ -19,7 +19,7 @@ spec: serviceAccountName: sonarr-operator containers: - name: operator - image: k3d-registry:5000/sonarr-operator:latest + image: ghcr.io/OWNER/sonarr-operator:latest imagePullPolicy: Always env: - name: RUST_LOG diff --git a/docs/TESTING.md b/docs/TESTING.md index 2c18478..6432dbc 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -4,11 +4,10 @@ This guide covers different approaches to testing the operator, from unit tests ## Prerequisites -- Rust toolchain (1.75+) +- Rust toolchain (1.88+) - Docker - kubectl -- [Kind](https://kind.sigs.k8s.io/) (for local Kubernetes testing) -- A Sonarr instance (for integration tests) +- [k3d](https://k3d.io/) or [Kind](https://kind.sigs.k8s.io/) for local Kubernetes clusters ## Quick Start @@ -27,8 +26,6 @@ make crds ## 1. Unit Testing -Run unit tests with: - ```bash make test # or with verbose output @@ -39,130 +36,97 @@ Unit tests are located in each module and test individual functions without exte --- -## 2. Local Development Testing - -### Run Operator Locally +## 2. Integration Testing -You can run the operator locally against any Kubernetes cluster: +Integration tests verify CRD schemas and validation against a live Kubernetes cluster (no Sonarr instance required). ```bash -# Ensure you have a valid kubeconfig -export KUBECONFIG=~/.kube/config +# Setup +make kind-create install -# Install CRDs first -make install +# Run +cargo test --test integration -- --ignored --test-threads=1 -# Run the operator with debug logging -make run-debug +# Cleanup +make kind-delete ``` -The operator will connect to your cluster and start reconciling Sonarr resources. - --- -## 3. Integration Testing with Kind +## 3. End-to-End Testing -[Kind](https://kind.sigs.k8s.io/) provides a local Kubernetes cluster using Docker containers. +E2E tests verify the full reconciliation loop: the operator watches CRDs, calls the Sonarr API, and updates resource status. -### Setup Kind Cluster +### Architecture -```bash -# Create a Kind cluster -make kind-create +The E2E flow mirrors the CI pipeline: -# Install CRDs -make install -``` +1. Create a Kubernetes cluster and install CRDs +2. Run the operator (locally or as a Deployment) +3. Create an API key Secret and apply the **Sonarr CR** +4. The operator reconciles the Sonarr CR → creates Deployment, Service, PVC +5. Sonarr boots with the deterministic API key +6. Tests create sub-resource CRs (Tags, RootFolders, etc.) and verify via the Sonarr API -### Deploy Test Sonarr Instance +### Local Setup ```bash -# Deploy a real Sonarr instance for testing -kubectl apply -f tests/e2e/fixtures/sonarr-deployment.yaml +# Create a k3d cluster with NodePort mapping (host 8989 → node 30989) +k3d cluster create sonarr-e2e --port 8989:30989@server:0 --no-lb -# Wait for Sonarr to be ready -kubectl wait --for=condition=available deployment/sonarr -n sonarr-test --timeout=120s - -# Port-forward to access Sonarr UI (optional) -kubectl port-forward -n sonarr-test svc/sonarr 8989:8989 -``` +# Install CRDs +make install -### Get Sonarr API Key +# Run the operator (terminal 1) +make run-debug -```bash -# Wait for Sonarr to initialize (creates config.xml) -sleep 30 +# Deploy Sonarr via its CRD (terminal 2) +export SONARR_API_KEY="my-test-api-key" +make e2e-deploy-sonarr -# Extract API key from Sonarr config -kubectl exec -n sonarr-test deployment/sonarr -- cat /config/config.xml | grep -oP '(?<=)[^<]+' +# Run the E2E tests +export SONARR_URL="http://localhost:8989" +make e2e ``` -### Update API Key Secret +### Fixture Files -Edit `tests/e2e/fixtures/01-sonarr-instance.yaml` and replace the placeholder API key with the actual value: - -```bash -# Or create the secret directly -kubectl create secret generic sonarr-api-key -n sonarr-test \ - --from-literal=api-key=YOUR_ACTUAL_API_KEY \ - --dry-run=client -o yaml | kubectl apply -f - -``` +The only fixture file is: -### Run Operator and Test Resources +- **`tests/e2e/fixtures/sonarr-instance.yaml`** — the Sonarr CR that the operator reconciles into a running Sonarr instance with `serviceType: NodePort` on port 30989. -```bash -# In terminal 1: Run the operator -make run-debug +All test resources (Tags, RootFolders, QualityProfiles, etc.) are created programmatically by the Rust test code. -# In terminal 2: Apply test resources -kubectl apply -f tests/e2e/fixtures/01-sonarr-instance.yaml -kubectl apply -f tests/e2e/fixtures/02-tags.yaml -kubectl apply -f tests/e2e/fixtures/03-root-folders.yaml +### Running E2E Tests -# Check the resources -kubectl get sonarrs,sonarrtags,sonarrrootfolders -n sonarr-test -o wide +```bash +# Standard run +make e2e -# Check status of a specific resource -kubectl describe sonarrtag test-tag-anime -n sonarr-test +# Verbose with debug logging +make e2e-verbose ``` -### Verify in Sonarr - -Access the Sonarr UI at http://localhost:8989 (if port-forwarded) and verify: -- Tags appear in Settings → Tags -- Root folders appear in Settings → Media Management - ### Cleanup ```bash -make kind-delete +make e2e-cleanup +k3d cluster delete sonarr-e2e ``` --- -## 4. Full E2E Testing with Docker Image +## 4. CI Pipeline -Build and deploy the operator as a container: +The GitHub Actions CI pipeline automates the full E2E flow: -```bash -# Create Kind cluster -make kind-create - -# Build Docker image -make docker - -# Load image into Kind -make kind-load - -# Deploy operator to cluster -make deploy +1. **Build** the operator image and push to GHCR +2. **Create** a k3d cluster with NodePort mapping +3. **Deploy** the operator as a Kubernetes Deployment (from the GHCR image) +4. **Apply** the Sonarr CR with a deterministic API key +5. **Run** E2E tests against the live Sonarr instance -# Apply test fixtures -kubectl apply -f tests/e2e/fixtures/ - -# Watch resources -kubectl get sonarrs,sonarrtags -n sonarr-test -w -``` +See `.github/workflows/ci.yml` for the full pipeline definition. --- @@ -171,35 +135,31 @@ kubectl get sonarrs,sonarrtags -n sonarr-test -w ### Test CRD Generation ```bash -# Generate all CRDs make crds -cat deploy/crds/crds.yaml +ls deploy/crds/ -# Generate single CRD +# Generate a single CRD make crds-single CRD=SonarrTag ``` -### Test Specific Resource Types +### Test a Specific Resource ```bash -# Apply and test tags kubectl apply -f - <` | +| Sonarr CR never becomes Ready | Check operator logs: `kubectl logs -n sonarr-operator-system deploy/sonarr-operator` | +| CRD not found | Run `make install` to apply CRDs | +| Finalizer stuck on delete | Check operator logs for cleanup errors | +| NodePort not reachable | Ensure k3d was created with `--port 8989:30989@server:0` | --- -## 7. CI/CD Integration - -For CI pipelines, use: - -```bash -# Run all checks -make ci - -# Full test including build -make all -``` - -Example GitHub Actions workflow: - -```yaml -name: CI -on: [push, pull_request] - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-action@stable - - run: make lint test build crds -``` - ---- - -## 8. Test Coverage - -Generate test coverage report: +## 7. Test Coverage ```bash cargo install cargo-tarpaulin diff --git a/tests/e2e/common.rs b/tests/e2e/common.rs index fe67f8a..128da7c 100644 --- a/tests/e2e/common.rs +++ b/tests/e2e/common.rs @@ -414,6 +414,38 @@ impl TestContext { ) .await } + "SonarrImportList" => { + delete_resource::( + &self.client, + &resource.namespace, + &resource.name, + ) + .await + } + "SonarrSeries" => { + delete_resource::( + &self.client, + &resource.namespace, + &resource.name, + ) + .await + } + "SonarrMetadata" => { + delete_resource::( + &self.client, + &resource.namespace, + &resource.name, + ) + .await + } + "SonarrLanguageProfile" => { + delete_resource::( + &self.client, + &resource.namespace, + &resource.name, + ) + .await + } _ => { tracing::warn!("Unknown resource kind for cleanup: {}", resource.kind); Ok(()) @@ -487,3 +519,7 @@ impl_has_conditions!(SonarrNamingConfig); impl_has_conditions!(SonarrIndexerConfig); impl_has_conditions!(SonarrDownloadClientConfig); impl_has_conditions!(SonarrQualityDefinition); +impl_has_conditions!(SonarrImportList); +impl_has_conditions!(SonarrSeries); +impl_has_conditions!(SonarrMetadata); +impl_has_conditions!(SonarrLanguageProfile); diff --git a/tests/e2e/fixtures/01-sonarr-instance.yaml b/tests/e2e/fixtures/01-sonarr-instance.yaml deleted file mode 100644 index e2f13ea..0000000 --- a/tests/e2e/fixtures/01-sonarr-instance.yaml +++ /dev/null @@ -1,7 +0,0 @@ -apiVersion: devopsarr.io/v1alpha1 -kind: Sonarr -metadata: - name: sonarr - namespace: default -spec: - image: lscr.io/linuxserver/sonarr:latest diff --git a/tests/e2e/fixtures/02-tags.yaml b/tests/e2e/fixtures/02-tags.yaml deleted file mode 100644 index 458c808..0000000 --- a/tests/e2e/fixtures/02-tags.yaml +++ /dev/null @@ -1,30 +0,0 @@ -# Test SonarrTag resources -apiVersion: devopsarr.io/v1alpha1 -kind: SonarrTag -metadata: - name: test-tag-anime - namespace: sonarr-test -spec: - sonarrInstanceRef: - name: test-sonarr - label: 'anime' ---- -apiVersion: devopsarr.io/v1alpha1 -kind: SonarrTag -metadata: - name: test-tag-documentary - namespace: sonarr-test -spec: - sonarrInstanceRef: - name: test-sonarr - label: 'documentary' ---- -apiVersion: devopsarr.io/v1alpha1 -kind: SonarrTag -metadata: - name: test-tag-kids - namespace: sonarr-test -spec: - sonarrInstanceRef: - name: test-sonarr - label: 'kids' diff --git a/tests/e2e/fixtures/03-root-folders.yaml b/tests/e2e/fixtures/03-root-folders.yaml deleted file mode 100644 index 4e7f3f9..0000000 --- a/tests/e2e/fixtures/03-root-folders.yaml +++ /dev/null @@ -1,20 +0,0 @@ -# Test SonarrRootFolder resources -apiVersion: devopsarr.io/v1alpha1 -kind: SonarrRootFolder -metadata: - name: test-root-tv - namespace: sonarr-test -spec: - sonarrInstanceRef: - name: test-sonarr - path: '/tv' ---- -apiVersion: devopsarr.io/v1alpha1 -kind: SonarrRootFolder -metadata: - name: test-root-anime - namespace: sonarr-test -spec: - sonarrInstanceRef: - name: test-sonarr - path: '/anime' diff --git a/tests/e2e/fixtures/cleanup.sh b/tests/e2e/fixtures/cleanup.sh deleted file mode 100644 index 5ab75a6..0000000 --- a/tests/e2e/fixtures/cleanup.sh +++ /dev/null @@ -1,27 +0,0 @@ -#!/bin/bash -# Cleanup script for E2E tests - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -NAMESPACE="${NAMESPACE:-default}" - -echo "=== Sonarr E2E Test Cleanup ===" - -# Delete test resources -echo "Cleaning up test resources..." -kubectl delete -f "${SCRIPT_DIR}/sonarr-instance.yaml" --ignore-not-found -kubectl delete secret sonarr-api-key -n "${NAMESPACE}" --ignore-not-found - -# Delete E2E test namespace -echo "Cleaning up E2E test namespace..." -kubectl delete namespace sonarr-e2e-test --ignore-not-found - -# Delete Sonarr deployment -echo "Cleaning up Sonarr deployment..." -kubectl delete -f "${SCRIPT_DIR}/sonarr-deployment.yaml" --ignore-not-found - -# Clean up local files -rm -f "${SCRIPT_DIR}/.sonarr-api-key" - -echo "=== Cleanup Complete ===" diff --git a/tests/e2e/fixtures/setup.sh b/tests/e2e/fixtures/setup.sh deleted file mode 100644 index 4e57f72..0000000 --- a/tests/e2e/fixtures/setup.sh +++ /dev/null @@ -1,102 +0,0 @@ -#!/bin/bash -# Setup script for E2E tests -# This script: -# 1. Deploys Sonarr -# 2. Waits for it to be ready -# 3. Extracts the API key -# 4. Creates the Sonarr CRD instance - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -NAMESPACE="${NAMESPACE:-default}" -TIMEOUT="${TIMEOUT:-300}" - -echo "=== Sonarr E2E Test Setup ===" - -# Step 1: Deploy Sonarr -echo "Step 1: Deploying Sonarr..." -kubectl apply -f "${SCRIPT_DIR}/sonarr-deployment.yaml" - -# Step 2: Wait for Sonarr to be ready -echo "Step 2: Waiting for Sonarr deployment to be ready..." -kubectl wait --for=condition=available deployment/sonarr -n "${NAMESPACE}" --timeout="${TIMEOUT}s" - -echo "Waiting for Sonarr pod to be ready..." -kubectl wait --for=condition=ready pod -l app=sonarr -n "${NAMESPACE}" --timeout="${TIMEOUT}s" - -# Give Sonarr time to initialize and generate API key -echo "Waiting for Sonarr to initialize..." -sleep 30 - -# Step 3: Extract API key from Sonarr config -echo "Step 3: Extracting API key from Sonarr..." -SONARR_POD=$(kubectl get pod -l app=sonarr -n "${NAMESPACE}" -o jsonpath='{.items[0].metadata.name}') - -# Try to get API key from config file -API_KEY="" -for i in {1..10}; do - API_KEY=$(kubectl exec "${SONARR_POD}" -n "${NAMESPACE}" -- cat /config/config.xml 2>/dev/null | grep -oP '(?<=)[^<]+' || true) - if [ -n "${API_KEY}" ]; then - break - fi - echo "Waiting for API key to be generated... (attempt $i/10)" - sleep 10 -done - -if [ -z "${API_KEY}" ]; then - echo "ERROR: Failed to extract API key from Sonarr" - echo "Trying alternative method..." - - # Try to get it from the API directly (requires port-forward) - kubectl port-forward "pod/${SONARR_POD}" 8989:8989 -n "${NAMESPACE}" & - PF_PID=$! - sleep 5 - - # Check if Sonarr is responding - curl -s http://localhost:8989/api/v3/system/status || true - - kill $PF_PID 2>/dev/null || true - - echo "Please check Sonarr logs for issues:" - kubectl logs "${SONARR_POD}" -n "${NAMESPACE}" --tail=50 - exit 1 -fi - -echo "API key extracted successfully" - -# Step 4: Update the secret with the real API key -echo "Step 4: Creating API key secret..." -kubectl create secret generic sonarr-api-key \ - --from-literal=api-key="${API_KEY}" \ - -n "${NAMESPACE}" \ - --dry-run=client -o yaml | kubectl apply -f - - -# Step 5: Create the Sonarr CRD instance -echo "Step 5: Creating Sonarr CRD instance..." -kubectl apply -f "${SCRIPT_DIR}/sonarr-instance.yaml" - -# Step 6: Export environment variables for tests -echo "" -echo "=== Setup Complete ===" -echo "" -echo "Sonarr is ready for E2E tests!" -echo "" -echo "To run E2E tests locally, set these environment variables:" -echo "" -echo " export SONARR_API_KEY='${API_KEY}'" -echo " export SONARR_URL='http://localhost:8989'" -echo "" -echo "Then port-forward Sonarr:" -echo " kubectl port-forward svc/sonarr 8989:8989 -n ${NAMESPACE}" -echo "" -echo "And run the operator:" -echo " cargo run" -echo "" -echo "Finally, run E2E tests:" -echo " cargo test --test e2e -- --ignored --test-threads=1" -echo "" - -# Create a file with the API key for CI usage -echo "${API_KEY}" > "${SCRIPT_DIR}/.sonarr-api-key" -echo "API key saved to ${SCRIPT_DIR}/.sonarr-api-key" diff --git a/tests/e2e/fixtures/sonarr-deployment.yaml b/tests/e2e/fixtures/sonarr-deployment.yaml deleted file mode 100644 index 19f45e5..0000000 --- a/tests/e2e/fixtures/sonarr-deployment.yaml +++ /dev/null @@ -1,114 +0,0 @@ -# Sonarr deployment for E2E tests -# This deploys a Sonarr instance that the operator will manage ---- -apiVersion: v1 -kind: Namespace -metadata: - name: default - labels: - app.kubernetes.io/managed-by: sonarr-e2e ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: sonarr-config - namespace: default -spec: - accessModes: - - ReadWriteOnce - resources: - requests: - storage: 1Gi ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: sonarr-data - namespace: default -spec: - accessModes: - - ReadWriteOnce - resources: - requests: - storage: 1Gi ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: sonarr - namespace: default - labels: - app: sonarr -spec: - replicas: 1 - selector: - matchLabels: - app: sonarr - template: - metadata: - labels: - app: sonarr - spec: - containers: - - name: sonarr - image: lscr.io/linuxserver/sonarr:latest - ports: - - containerPort: 8989 - name: http - env: - - name: PUID - value: '1000' - - name: PGID - value: '1000' - - name: TZ - value: 'UTC' - volumeMounts: - - name: config - mountPath: /config - - name: data - mountPath: /data - resources: - requests: - memory: '256Mi' - cpu: '100m' - limits: - memory: '512Mi' - cpu: '500m' - readinessProbe: - httpGet: - path: /api/v3/system/status - port: 8989 - initialDelaySeconds: 30 - periodSeconds: 10 - timeoutSeconds: 5 - livenessProbe: - httpGet: - path: /api/v3/system/status - port: 8989 - initialDelaySeconds: 60 - periodSeconds: 30 - timeoutSeconds: 10 - volumes: - - name: config - persistentVolumeClaim: - claimName: sonarr-config - - name: data - persistentVolumeClaim: - claimName: sonarr-data ---- -apiVersion: v1 -kind: Service -metadata: - name: sonarr - namespace: default - labels: - app: sonarr -spec: - type: ClusterIP - ports: - - port: 8989 - targetPort: 8989 - protocol: TCP - name: http - selector: - app: sonarr diff --git a/tests/e2e/fixtures/sonarr-instance.yaml b/tests/e2e/fixtures/sonarr-instance.yaml index 0ba85be..3b90d4b 100644 --- a/tests/e2e/fixtures/sonarr-instance.yaml +++ b/tests/e2e/fixtures/sonarr-instance.yaml @@ -1,18 +1,13 @@ # Sonarr CRD instance for E2E tests -# This creates the Sonarr custom resource that the operator will use to connect ---- -apiVersion: v1 -kind: Secret -metadata: - name: sonarr-api-key - namespace: default - labels: - app: sonarr -type: Opaque -stringData: - # This will be populated by the setup script after Sonarr starts - # The API key is generated by Sonarr on first run - api-key: 'placeholder-will-be-replaced' +# +# The operator reconciles this CR to create: +# - A Deployment running Sonarr (with init container for config.xml) +# - A NodePort Service exposed on port 30989 +# - A PVC for Sonarr configuration storage +# +# Prerequisites: +# - The API key Secret must be created before applying this CR +# - The k3d cluster must map host port 8989 to node port 30989 --- apiVersion: devopsarr.io/v1alpha1 kind: Sonarr @@ -20,9 +15,11 @@ metadata: name: sonarr namespace: default spec: - # URL of the Sonarr instance - url: http://sonarr.default.svc.cluster.local:8989 - # Reference to the secret containing the API key apiKeySecretRef: name: sonarr-api-key key: api-key + service: + serviceType: NodePort + nodePort: 30989 + config: + authenticationMethod: None diff --git a/tests/e2e/scenarios/download_client_config_lifecycle.rs b/tests/e2e/scenarios/download_client_config_lifecycle.rs new file mode 100644 index 0000000..6f794db --- /dev/null +++ b/tests/e2e/scenarios/download_client_config_lifecycle.rs @@ -0,0 +1,224 @@ +//! E2E tests for DownloadClientConfig resource +//! +//! Tests global download client configuration management via the SonarrDownloadClientConfig CRD. + +use crate::common::*; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::api::{Api, Patch, PatchParams}; +use sonarr_operator::crds::*; +use std::time::Duration; + +/// Test DownloadClientConfig updates global download client settings in Sonarr +#[tokio::test] +#[ignore = "requires E2E environment - run with: cargo test --test e2e -- --ignored"] +async fn test_download_client_config_update() { + let mut ctx = TestContext::new() + .await + .expect("Failed to create test context"); + setup_e2e_namespace(&ctx.client) + .await + .expect("Failed to setup namespace"); + + // Get current config from Sonarr for comparison + let original_config = ctx + .sonarr + .get_download_client_config() + .await + .expect("Failed to get current download client config"); + tracing::info!( + "Original config: enable_completed_download_handling = {}, auto_redownload_failed = {}", + original_config.enable_completed_download_handling, + original_config.auto_redownload_failed + ); + + // Create DownloadClientConfig CR + let config_name = unique_name("e2e-dcc"); + ctx.register_cleanup("SonarrDownloadClientConfig", E2E_NAMESPACE, &config_name); + + let new_completed_handling = !original_config.enable_completed_download_handling; + let new_auto_redownload = !original_config.auto_redownload_failed; + + let config = SonarrDownloadClientConfig { + metadata: ObjectMeta { + name: Some(config_name.clone()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrDownloadClientConfigSpec { + enable_completed_download_handling: Some(new_completed_handling), + auto_redownload_failed: Some(new_auto_redownload), + sonarr_instance_ref: SonarrInstanceRef { + name: "sonarr".to_string(), + namespace: Some("default".to_string()), + }, + ..Default::default() + }, + status: None, + }; + + apply_resource(&ctx.client, &config) + .await + .expect("Failed to create download client config"); + + // Wait for Ready + wait_for_ready::( + &ctx.client, + E2E_NAMESPACE, + &config_name, + E2E_TIMEOUT, + ) + .await + .expect("DownloadClientConfig never became ready"); + + // Give Sonarr time to apply the change + tokio::time::sleep(Duration::from_secs(3)).await; + + // Verify in Sonarr + let updated_config = ctx + .sonarr + .get_download_client_config() + .await + .expect("Failed to get updated download client config"); + + assert_eq!( + updated_config.enable_completed_download_handling, new_completed_handling, + "Enable completed download handling should be updated" + ); + assert_eq!( + updated_config.auto_redownload_failed, new_auto_redownload, + "Auto redownload failed should be updated" + ); + tracing::info!("✓ DownloadClientConfig verified in Sonarr"); + + // Restore original value + let api: Api = Api::namespaced(ctx.client.clone(), E2E_NAMESPACE); + let restore_patch = serde_json::json!({ + "apiVersion": "devopsarr.io/v1alpha1", + "kind": "SonarrDownloadClientConfig", + "metadata": { + "name": config_name, + "namespace": E2E_NAMESPACE + }, + "spec": { + "enableCompletedDownloadHandling": original_config.enable_completed_download_handling, + "autoRedownloadFailed": original_config.auto_redownload_failed, + "sonarrInstanceRef": { + "name": "sonarr", + "namespace": "default" + } + } + }); + + api.patch( + &config_name, + &PatchParams::apply("sonarr-e2e-test").force(), + &Patch::Apply(&restore_patch), + ) + .await + .expect("Failed to restore config"); + + tokio::time::sleep(Duration::from_secs(3)).await; + tracing::info!("✓ DownloadClientConfig restored to original values"); + + ctx.cleanup().await; +} + +/// Test DownloadClientConfig singleton constraint - only one per Sonarr instance +#[tokio::test] +#[ignore = "requires E2E environment - run with: cargo test --test e2e -- --ignored"] +async fn test_download_client_config_singleton_constraint() { + let mut ctx = TestContext::new() + .await + .expect("Failed to create test context"); + setup_e2e_namespace(&ctx.client) + .await + .expect("Failed to setup namespace"); + + // Create first DownloadClientConfig + let config1_name = unique_name("e2e-dcc-1"); + ctx.register_cleanup("SonarrDownloadClientConfig", E2E_NAMESPACE, &config1_name); + + let config1 = SonarrDownloadClientConfig { + metadata: ObjectMeta { + name: Some(config1_name.clone()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrDownloadClientConfigSpec { + enable_completed_download_handling: Some(true), + sonarr_instance_ref: SonarrInstanceRef { + name: "sonarr".to_string(), + namespace: Some("default".to_string()), + }, + ..Default::default() + }, + status: None, + }; + + apply_resource(&ctx.client, &config1) + .await + .expect("Failed to create first download client config"); + + wait_for_ready::( + &ctx.client, + E2E_NAMESPACE, + &config1_name, + E2E_TIMEOUT, + ) + .await + .expect("First download client config never became ready"); + + tracing::info!("First DownloadClientConfig created successfully"); + + // Try to create second config for same instance + let config2_name = unique_name("e2e-dcc-2"); + ctx.register_cleanup("SonarrDownloadClientConfig", E2E_NAMESPACE, &config2_name); + + let config2 = SonarrDownloadClientConfig { + metadata: ObjectMeta { + name: Some(config2_name.clone()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrDownloadClientConfigSpec { + enable_completed_download_handling: Some(false), + sonarr_instance_ref: SonarrInstanceRef { + name: "sonarr".to_string(), + namespace: Some("default".to_string()), + }, + ..Default::default() + }, + status: None, + }; + + apply_resource(&ctx.client, &config2) + .await + .expect("Failed to create second download client config CR"); + + // Wait for operator to process + tokio::time::sleep(Duration::from_secs(10)).await; + + // Check that second config has a conflict/error condition + let api: Api = Api::namespaced(ctx.client.clone(), E2E_NAMESPACE); + let config2_status = api + .get(&config2_name) + .await + .expect("Failed to get second config"); + + if let Some(status) = &config2_status.status { + let has_conflict = status + .conditions + .iter() + .any(|c| (c.type_ == "Ready" && c.status == "False") || c.type_ == "Conflict"); + + if has_conflict { + tracing::info!( + "✓ Singleton constraint enforced - second DownloadClientConfig has error/conflict condition" + ); + } else { + tracing::warn!("Second config doesn't show conflict - singleton may not be enforced at controller level"); + } + } + + ctx.cleanup().await; +} diff --git a/tests/e2e/scenarios/indexer_config_lifecycle.rs b/tests/e2e/scenarios/indexer_config_lifecycle.rs new file mode 100644 index 0000000..a0313ed --- /dev/null +++ b/tests/e2e/scenarios/indexer_config_lifecycle.rs @@ -0,0 +1,225 @@ +//! E2E tests for IndexerConfig resource +//! +//! Tests global indexer configuration management via the SonarrIndexerConfig CRD. + +use crate::common::*; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::api::{Api, Patch, PatchParams}; +use sonarr_operator::crds::*; +use std::time::Duration; + +/// Test IndexerConfig updates global indexer settings in Sonarr +#[tokio::test] +#[ignore = "requires E2E environment - run with: cargo test --test e2e -- --ignored"] +async fn test_indexer_config_update() { + let mut ctx = TestContext::new() + .await + .expect("Failed to create test context"); + setup_e2e_namespace(&ctx.client) + .await + .expect("Failed to setup namespace"); + + // Get current config from Sonarr for comparison + let original_config = ctx + .sonarr + .get_indexer_config() + .await + .expect("Failed to get current indexer config"); + tracing::info!( + "Original config: rss_sync_interval = {}, minimum_age = {}", + original_config.rss_sync_interval, + original_config.minimum_age + ); + + // Create IndexerConfig CR + let config_name = unique_name("e2e-ic"); + ctx.register_cleanup("SonarrIndexerConfig", E2E_NAMESPACE, &config_name); + + let new_rss_interval = if original_config.rss_sync_interval == 15 { + 25 + } else { + 15 + }; + let new_minimum_age = if original_config.minimum_age == 0 { + 5 + } else { + 0 + }; + + let config = SonarrIndexerConfig { + metadata: ObjectMeta { + name: Some(config_name.clone()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrIndexerConfigSpec { + rss_sync_interval: Some(new_rss_interval), + minimum_age: Some(new_minimum_age), + retention: Some(0), + maximum_size: Some(0), + sonarr_instance_ref: SonarrInstanceRef { + name: "sonarr".to_string(), + namespace: Some("default".to_string()), + }, + }, + status: None, + }; + + apply_resource(&ctx.client, &config) + .await + .expect("Failed to create indexer config"); + + // Wait for Ready + wait_for_ready::(&ctx.client, E2E_NAMESPACE, &config_name, E2E_TIMEOUT) + .await + .expect("IndexerConfig never became ready"); + + // Give Sonarr time to apply the change + tokio::time::sleep(Duration::from_secs(3)).await; + + // Verify in Sonarr + let updated_config = ctx + .sonarr + .get_indexer_config() + .await + .expect("Failed to get updated indexer config"); + + assert_eq!( + updated_config.rss_sync_interval, new_rss_interval, + "RSS sync interval should be updated" + ); + assert_eq!( + updated_config.minimum_age, new_minimum_age, + "Minimum age should be updated" + ); + tracing::info!("✓ IndexerConfig verified in Sonarr"); + + // Restore original value + let api: Api = Api::namespaced(ctx.client.clone(), E2E_NAMESPACE); + let restore_patch = serde_json::json!({ + "apiVersion": "devopsarr.io/v1alpha1", + "kind": "SonarrIndexerConfig", + "metadata": { + "name": config_name, + "namespace": E2E_NAMESPACE + }, + "spec": { + "rssSyncInterval": original_config.rss_sync_interval, + "minimumAge": original_config.minimum_age, + "sonarrInstanceRef": { + "name": "sonarr", + "namespace": "default" + } + } + }); + + api.patch( + &config_name, + &PatchParams::apply("sonarr-e2e-test").force(), + &Patch::Apply(&restore_patch), + ) + .await + .expect("Failed to restore config"); + + tokio::time::sleep(Duration::from_secs(3)).await; + tracing::info!("✓ IndexerConfig restored to original values"); + + ctx.cleanup().await; +} + +/// Test IndexerConfig singleton constraint - only one per Sonarr instance +#[tokio::test] +#[ignore = "requires E2E environment - run with: cargo test --test e2e -- --ignored"] +async fn test_indexer_config_singleton_constraint() { + let mut ctx = TestContext::new() + .await + .expect("Failed to create test context"); + setup_e2e_namespace(&ctx.client) + .await + .expect("Failed to setup namespace"); + + // Create first IndexerConfig + let config1_name = unique_name("e2e-ic-1"); + ctx.register_cleanup("SonarrIndexerConfig", E2E_NAMESPACE, &config1_name); + + let config1 = SonarrIndexerConfig { + metadata: ObjectMeta { + name: Some(config1_name.clone()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrIndexerConfigSpec { + rss_sync_interval: Some(15), + sonarr_instance_ref: SonarrInstanceRef { + name: "sonarr".to_string(), + namespace: Some("default".to_string()), + }, + ..Default::default() + }, + status: None, + }; + + apply_resource(&ctx.client, &config1) + .await + .expect("Failed to create first indexer config"); + + wait_for_ready::(&ctx.client, E2E_NAMESPACE, &config1_name, E2E_TIMEOUT) + .await + .expect("First indexer config never became ready"); + + tracing::info!("First IndexerConfig created successfully"); + + // Try to create second config for same instance + let config2_name = unique_name("e2e-ic-2"); + ctx.register_cleanup("SonarrIndexerConfig", E2E_NAMESPACE, &config2_name); + + let config2 = SonarrIndexerConfig { + metadata: ObjectMeta { + name: Some(config2_name.clone()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrIndexerConfigSpec { + rss_sync_interval: Some(25), + sonarr_instance_ref: SonarrInstanceRef { + name: "sonarr".to_string(), + namespace: Some("default".to_string()), + }, + ..Default::default() + }, + status: None, + }; + + apply_resource(&ctx.client, &config2) + .await + .expect("Failed to create second indexer config CR"); + + // Wait for operator to process + tokio::time::sleep(Duration::from_secs(10)).await; + + // Check that second config has a conflict/error condition + let api: Api = Api::namespaced(ctx.client.clone(), E2E_NAMESPACE); + let config2_status = api + .get(&config2_name) + .await + .expect("Failed to get second config"); + + if let Some(status) = &config2_status.status { + let has_conflict = status + .conditions + .iter() + .any(|c| (c.type_ == "Ready" && c.status == "False") || c.type_ == "Conflict"); + + if has_conflict { + tracing::info!( + "✓ Singleton constraint enforced - second IndexerConfig has error/conflict condition" + ); + } else { + tracing::warn!( + "Second config doesn't show conflict - singleton may not be enforced at controller level" + ); + } + } + + ctx.cleanup().await; +} diff --git a/tests/e2e/scenarios/mod.rs b/tests/e2e/scenarios/mod.rs index 9c9c64b..4096c45 100644 --- a/tests/e2e/scenarios/mod.rs +++ b/tests/e2e/scenarios/mod.rs @@ -2,10 +2,12 @@ //! //! Each module tests a specific resource type or cross-resource dependency. -pub mod tag_lifecycle; -pub mod root_folder_lifecycle; -pub mod quality_profile_lifecycle; -pub mod custom_format_lifecycle; -pub mod cross_resource_dependencies; pub mod config_resources; +pub mod cross_resource_dependencies; +pub mod custom_format_lifecycle; +pub mod download_client_config_lifecycle; pub mod error_recovery; +pub mod indexer_config_lifecycle; +pub mod quality_profile_lifecycle; +pub mod root_folder_lifecycle; +pub mod tag_lifecycle; diff --git a/tests/e2e/sonarr_client.rs b/tests/e2e/sonarr_client.rs index c11a3ae..cceb9ee 100644 --- a/tests/e2e/sonarr_client.rs +++ b/tests/e2e/sonarr_client.rs @@ -3,8 +3,8 @@ //! This client directly queries the Sonarr API to verify that resources //! created by the operator actually exist in Sonarr. -use anyhow::{anyhow, Result}; -use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE}; +use anyhow::{Result, anyhow}; +use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderValue}; use serde::{Deserialize, Serialize}; use std::time::Duration; @@ -334,6 +334,32 @@ impl SonarrTestClient { .error_for_status()?; Ok(()) } + + // ========== Indexer Config ========== + + /// Get global indexer config from Sonarr + pub async fn get_indexer_config(&self) -> Result { + let resp = self + .client + .get(format!("{}/api/v3/config/indexer", self.base_url)) + .send() + .await? + .error_for_status()?; + Ok(resp.json().await?) + } + + // ========== Download Client Config ========== + + /// Get global download client config from Sonarr + pub async fn get_download_client_config(&self) -> Result { + let resp = self + .client + .get(format!("{}/api/v3/config/downloadclient", self.base_url)) + .send() + .await? + .error_for_status()?; + Ok(resp.json().await?) + } } // ========== API Response Types ========== @@ -478,3 +504,31 @@ pub struct DelayProfile { #[serde(default)] pub tags: Vec, } + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IndexerConfig { + pub id: i32, + #[serde(default)] + pub minimum_age: i32, + #[serde(default)] + pub retention: i32, + #[serde(default)] + pub maximum_size: i32, + #[serde(default)] + pub rss_sync_interval: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DownloadClientConfig { + pub id: i32, + #[serde(default)] + pub download_client_working_folders: String, + #[serde(default)] + pub enable_completed_download_handling: bool, + #[serde(default)] + pub auto_redownload_failed: bool, + #[serde(default)] + pub auto_redownload_failed_from_interactive_search: bool, +} From d558a45543064d5ec9350bc904f9b620cbdb9dd3 Mon Sep 17 00:00:00 2001 From: Fuochi Date: Sat, 28 Feb 2026 10:28:55 +0100 Subject: [PATCH 07/22] chore: claudio fmt and lint --- .dockerignore | 3 +- src/controllers/auto_tag.rs | 11 +- src/controllers/custom_format.rs | 11 +- src/controllers/delay_profile.rs | 69 ++++++---- src/controllers/download_client.rs | 75 ++++++++--- src/controllers/download_client_config.rs | 3 +- src/controllers/import_list.rs | 89 +++++++++---- src/controllers/indexer.rs | 90 ++++++++----- src/controllers/indexer_config.rs | 9 +- src/controllers/language_profile.rs | 9 +- src/controllers/media_management_config.rs | 21 +-- src/controllers/metadata.rs | 73 +++++++---- src/controllers/naming_config.rs | 5 +- src/controllers/notification.rs | 110 +++++++++++----- src/controllers/quality_profile.rs | 9 +- src/controllers/root_folder.rs | 9 +- src/controllers/series.rs | 106 ++++++++++----- src/controllers/sonarr.rs | 48 +++---- src/controllers/tag.rs | 9 +- src/controllers/traits.rs | 71 ++++++---- src/controllers/utils.rs | 9 +- src/crds/notification.rs | 2 +- src/lib.rs | 4 +- tests/e2e/scenarios/config_resources.rs | 124 ++++++++++++------ .../e2e/scenarios/custom_format_lifecycle.rs | 27 ++-- .../download_client_config_lifecycle.rs | 4 +- .../scenarios/quality_profile_lifecycle.rs | 71 +++++----- tests/e2e/scenarios/root_folder_lifecycle.rs | 33 +++-- .../integration/download_client_config_crd.rs | 9 +- tests/integration/indexer_config_crd.rs | 4 +- .../media_management_config_crd.rs | 8 +- tests/integration/naming_config_crd.rs | 4 +- 32 files changed, 716 insertions(+), 413 deletions(-) diff --git a/.dockerignore b/.dockerignore index 58c5905..c35ff1e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,5 +4,4 @@ .vscode .cursorrules .idea -Cargo.lock -Dockerfile \ No newline at end of file +Dockerfile diff --git a/src/controllers/auto_tag.rs b/src/controllers/auto_tag.rs index 8f1d17d..59a36e9 100644 --- a/src/controllers/auto_tag.rs +++ b/src/controllers/auto_tag.rs @@ -49,7 +49,7 @@ async fn reconcile_apply(auto_tag: Arc, ctx: Arc) -> Res .spec .specifications .iter() - .map(|spec| convert_specification(spec)) + .map(convert_specification) .collect(); resource.specifications = Some(Some(specs)); @@ -142,12 +142,11 @@ async fn reconcile_cleanup(auto_tag: Arc, ctx: Arc) -> R auto_tag.name_any() ); - if let Some(id) = auto_tag.status.as_ref().and_then(|s| s.id) { - if let Ok(config) = + if let Some(id) = auto_tag.status.as_ref().and_then(|s| s.id) + && let Ok(config) = get_sonarr_config(&ctx, client, &namespace, &auto_tag.spec.sonarr_instance_ref).await - { - let _ = auto_tagging_api::delete_auto_tagging(&config, id).await; - } + { + let _ = auto_tagging_api::delete_auto_tagging(&config, id).await; } Ok(Action::await_change()) diff --git a/src/controllers/custom_format.rs b/src/controllers/custom_format.rs index 1cae81f..4f51eec 100644 --- a/src/controllers/custom_format.rs +++ b/src/controllers/custom_format.rs @@ -49,7 +49,7 @@ async fn reconcile_apply(cf: Arc, ctx: Arc) -> Resu .spec .specifications .iter() - .map(|spec| convert_specification(spec)) + .map(convert_specification) .collect(); resource.specifications = Some(Some(specs)); @@ -143,12 +143,11 @@ async fn reconcile_cleanup(cf: Arc, ctx: Arc) -> Re cf.name_any() ); - if let Some(id) = cf.status.as_ref().and_then(|s| s.id) { - if let Ok(config) = + if let Some(id) = cf.status.as_ref().and_then(|s| s.id) + && let Ok(config) = get_sonarr_config(&ctx, client, &namespace, &cf.spec.sonarr_instance_ref).await - { - let _ = custom_format_api::delete_custom_format(&config, id).await; - } + { + let _ = custom_format_api::delete_custom_format(&config, id).await; } Ok(Action::await_change()) diff --git a/src/controllers/delay_profile.rs b/src/controllers/delay_profile.rs index 756317c..1de4b9e 100644 --- a/src/controllers/delay_profile.rs +++ b/src/controllers/delay_profile.rs @@ -8,23 +8,19 @@ use tracing::info; use sonarr::apis::delay_profile_api; use sonarr::models::DelayProfileResource; -use crate::crds::{SonarrDelayProfile, SonarrDelayProfileStatus}; +use crate::Context; use crate::crds::delay_profile::DownloadProtocol; +use crate::crds::{SonarrDelayProfile, SonarrDelayProfileStatus}; use crate::error::{Error, Result}; -use crate::Context; -use super::traits::{run_controller, reconcile_with_finalizer, REQUEUE_DURATION}; use super::tag::get_sonarr_config; +use super::traits::{REQUEUE_DURATION, reconcile_with_finalizer, run_controller}; use super::{ready_condition, update_conditions}; /// Start the SonarrDelayProfile controller pub async fn run(client: Client, context: Arc) { - run_controller::( - client, - context, - "SonarrDelayProfile", - reconcile, - ).await; + run_controller::(client, context, "SonarrDelayProfile", reconcile) + .await; } async fn reconcile(obj: Arc, ctx: Arc) -> Result { @@ -33,12 +29,15 @@ async fn reconcile(obj: Arc, ctx: Arc) -> Result, ctx: Arc) -> Result { let client = &ctx.client; - let namespace = profile.namespace().ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + let namespace = profile + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; let name = profile.name_any(); info!("Reconciling SonarrDelayProfile: {}/{}", namespace, name); - let config = get_sonarr_config(&ctx, client, &namespace, &profile.spec.sonarr_instance_ref).await?; + let config = + get_sonarr_config(&ctx, client, &namespace, &profile.spec.sonarr_instance_ref).await?; // Build delay profile resource let mut resource = DelayProfileResource::new(); @@ -48,14 +47,21 @@ async fn reconcile_apply(profile: Arc, ctx: Arc) -> resource.usenet_delay = Some(profile.spec.usenet_delay); resource.torrent_delay = Some(profile.spec.torrent_delay); resource.bypass_if_highest_quality = Some(profile.spec.bypass_if_highest_quality); - resource.bypass_if_above_custom_format_score = Some(profile.spec.bypass_if_above_custom_format_score); + resource.bypass_if_above_custom_format_score = + Some(profile.spec.bypass_if_above_custom_format_score); resource.minimum_custom_format_score = Some(profile.spec.minimum_custom_format_score); resource.order = Some(profile.spec.order); resource.tags = Some(Some(profile.spec.tags.clone())); let sonarr_profile = if let Some(id) = profile.status.as_ref().and_then(|s| s.id) { resource.id = Some(id); - match delay_profile_api::update_delay_profile(&config, &id.to_string(), Some(resource.clone())).await { + match delay_profile_api::update_delay_profile( + &config, + &id.to_string(), + Some(resource.clone()), + ) + .await + { Ok(p) => p, Err(_) => { resource.id = None; @@ -70,8 +76,15 @@ async fn reconcile_apply(profile: Arc, ctx: Arc) -> // Update status let api: Api = Api::namespaced(client.clone(), &namespace); - let mut conditions = profile.status.as_ref().map(|s| s.conditions.clone()).unwrap_or_default(); - update_conditions(&mut conditions, ready_condition(true, "Synced", "Delay profile synchronized with Sonarr")); + let mut conditions = profile + .status + .as_ref() + .map(|s| s.conditions.clone()) + .unwrap_or_default(); + update_conditions( + &mut conditions, + ready_condition(true, "Synced", "Delay profile synchronized with Sonarr"), + ); let status = SonarrDelayProfileStatus { conditions, @@ -80,7 +93,8 @@ async fn reconcile_apply(profile: Arc, ctx: Arc) -> }; let status_patch = serde_json::json!({ "status": status }); - api.patch_status(&name, &PatchParams::default(), &Patch::Merge(&status_patch)).await?; + api.patch_status(&name, &PatchParams::default(), &Patch::Merge(&status_patch)) + .await?; Ok(Action::requeue(REQUEUE_DURATION)) } @@ -94,14 +108,21 @@ fn convert_protocol(protocol: &DownloadProtocol) -> sonarr::models::DownloadProt async fn reconcile_cleanup(profile: Arc, ctx: Arc) -> Result { let client = &ctx.client; - let namespace = profile.namespace().ok_or(Error::MissingObjectKey(".metadata.namespace"))?; - - info!("Cleaning up SonarrDelayProfile: {}/{}", namespace, profile.name_any()); - - if let Some(id) = profile.status.as_ref().and_then(|s| s.id) { - if let Ok(config) = get_sonarr_config(&ctx, client, &namespace, &profile.spec.sonarr_instance_ref).await { - let _ = delay_profile_api::delete_delay_profile(&config, id).await; - } + let namespace = profile + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + + info!( + "Cleaning up SonarrDelayProfile: {}/{}", + namespace, + profile.name_any() + ); + + if let Some(id) = profile.status.as_ref().and_then(|s| s.id) + && let Ok(config) = + get_sonarr_config(&ctx, client, &namespace, &profile.spec.sonarr_instance_ref).await + { + let _ = delay_profile_api::delete_delay_profile(&config, id).await; } Ok(Action::await_change()) diff --git a/src/controllers/download_client.rs b/src/controllers/download_client.rs index ab9819e..278b496 100644 --- a/src/controllers/download_client.rs +++ b/src/controllers/download_client.rs @@ -8,13 +8,13 @@ use tracing::info; use sonarr::apis::download_client_api; use sonarr::models::DownloadClientResource; -use crate::crds::{SonarrDownloadClient, SonarrDownloadClientStatus}; +use crate::Context; use crate::crds::download_client::DownloadClientType; +use crate::crds::{SonarrDownloadClient, SonarrDownloadClientStatus}; use crate::error::{Error, Result}; -use crate::Context; -use super::traits::{run_controller, reconcile_with_finalizer, REQUEUE_DURATION}; use super::tag::get_sonarr_config; +use super::traits::{REQUEUE_DURATION, reconcile_with_finalizer, run_controller}; use super::{ready_condition, update_conditions}; /// Start the SonarrDownloadClient controller @@ -24,7 +24,8 @@ pub async fn run(client: Client, context: Arc) { context, "SonarrDownloadClient", reconcile, - ).await; + ) + .await; } async fn reconcile(obj: Arc, ctx: Arc) -> Result { @@ -33,7 +34,9 @@ async fn reconcile(obj: Arc, ctx: Arc) -> Result< async fn reconcile_apply(dc: Arc, ctx: Arc) -> Result { let client = &ctx.client; - let namespace = dc.namespace().ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + let namespace = dc + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; let name = dc.name_any(); info!("Reconciling SonarrDownloadClient: {}/{}", namespace, name); @@ -43,7 +46,9 @@ async fn reconcile_apply(dc: Arc, ctx: Arc) -> Re // Build download client resource let mut dc_resource = DownloadClientResource::new(); dc_resource.name = Some(Some(dc.spec.name.clone())); - dc_resource.implementation = Some(Some(get_implementation_name(&dc.spec.download_client_type).to_string())); + dc_resource.implementation = Some(Some( + get_implementation_name(&dc.spec.download_client_type).to_string(), + )); dc_resource.enable = Some(dc.spec.enable); dc_resource.priority = Some(dc.spec.priority); dc_resource.remove_completed_downloads = Some(dc.spec.remove_completed_downloads); @@ -52,26 +57,45 @@ async fn reconcile_apply(dc: Arc, ctx: Arc) -> Re let sonarr_dc = if let Some(id) = dc.status.as_ref().and_then(|s| s.id) { dc_resource.id = Some(id); - match download_client_api::update_download_client(&config, id, Some(false), Some(dc_resource.clone())).await { + match download_client_api::update_download_client( + &config, + id, + Some(false), + Some(dc_resource.clone()), + ) + .await + { Ok(d) => d, Err(_) => { dc_resource.id = None; - download_client_api::create_download_client(&config, Some(false), Some(dc_resource)).await? + download_client_api::create_download_client(&config, Some(false), Some(dc_resource)) + .await? } } } else { let existing = download_client_api::list_download_client(&config).await?; - if let Some(existing_dc) = existing.iter().find(|d| d.name.as_ref().and_then(|n| n.as_ref()) == Some(&dc.spec.name)) { + if let Some(existing_dc) = existing + .iter() + .find(|d| d.name.as_ref().and_then(|n| n.as_ref()) == Some(&dc.spec.name)) + { existing_dc.clone() } else { - download_client_api::create_download_client(&config, Some(false), Some(dc_resource)).await? + download_client_api::create_download_client(&config, Some(false), Some(dc_resource)) + .await? } }; // Update status let clients_api: Api = Api::namespaced(client.clone(), &namespace); - let mut conditions = dc.status.as_ref().map(|s| s.conditions.clone()).unwrap_or_default(); - update_conditions(&mut conditions, ready_condition(true, "Synced", "Download client synchronized with Sonarr")); + let mut conditions = dc + .status + .as_ref() + .map(|s| s.conditions.clone()) + .unwrap_or_default(); + update_conditions( + &mut conditions, + ready_condition(true, "Synced", "Download client synchronized with Sonarr"), + ); let status = SonarrDownloadClientStatus { conditions, @@ -80,7 +104,9 @@ async fn reconcile_apply(dc: Arc, ctx: Arc) -> Re }; let status_patch = serde_json::json!({ "status": status }); - clients_api.patch_status(&name, &PatchParams::default(), &Patch::Merge(&status_patch)).await?; + clients_api + .patch_status(&name, &PatchParams::default(), &Patch::Merge(&status_patch)) + .await?; Ok(Action::requeue(REQUEUE_DURATION)) } @@ -109,14 +135,21 @@ fn get_implementation_name(dc_type: &DownloadClientType) -> &'static str { async fn reconcile_cleanup(dc: Arc, ctx: Arc) -> Result { let client = &ctx.client; - let namespace = dc.namespace().ok_or(Error::MissingObjectKey(".metadata.namespace"))?; - - info!("Cleaning up SonarrDownloadClient: {}/{}", namespace, dc.name_any()); - - if let Some(id) = dc.status.as_ref().and_then(|s| s.id) { - if let Ok(config) = get_sonarr_config(&ctx, client, &namespace, &dc.spec.sonarr_instance_ref).await { - let _ = download_client_api::delete_download_client(&config, id).await; - } + let namespace = dc + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + + info!( + "Cleaning up SonarrDownloadClient: {}/{}", + namespace, + dc.name_any() + ); + + if let Some(id) = dc.status.as_ref().and_then(|s| s.id) + && let Ok(config) = + get_sonarr_config(&ctx, client, &namespace, &dc.spec.sonarr_instance_ref).await + { + let _ = download_client_api::delete_download_client(&config, id).await; } Ok(Action::await_change()) diff --git a/src/controllers/download_client_config.rs b/src/controllers/download_client_config.rs index 8742932..b379ffb 100644 --- a/src/controllers/download_client_config.rs +++ b/src/controllers/download_client_config.rs @@ -133,8 +133,7 @@ async fn reconcile_apply( get_sonarr_config(&ctx, client, &namespace, &config.spec.sonarr_instance_ref).await?; // Get existing config (there's only one, id=1) - let existing = - download_client_config_api::get_download_client_config(&sonarr_config).await?; + let existing = download_client_config_api::get_download_client_config(&sonarr_config).await?; // Build update resource let mut resource = DownloadClientConfigResource::new(); diff --git a/src/controllers/import_list.rs b/src/controllers/import_list.rs index 75ee8e3..e86f4da 100644 --- a/src/controllers/import_list.rs +++ b/src/controllers/import_list.rs @@ -8,23 +8,18 @@ use tracing::info; use sonarr::apis::import_list_api; use sonarr::models::ImportListResource; -use crate::crds::{SonarrImportList, SonarrImportListStatus}; +use crate::Context; use crate::crds::import_list::{ImportListType, MonitorTypes, NewItemMonitorTypes, SeriesTypes}; +use crate::crds::{SonarrImportList, SonarrImportListStatus}; use crate::error::{Error, Result}; -use crate::Context; -use super::traits::{run_controller, reconcile_with_finalizer, REQUEUE_DURATION}; use super::tag::get_sonarr_config; +use super::traits::{REQUEUE_DURATION, reconcile_with_finalizer, run_controller}; use super::{ready_condition, update_conditions}; /// Start the SonarrImportList controller pub async fn run(client: Client, context: Arc) { - run_controller::( - client, - context, - "SonarrImportList", - reconcile, - ).await; + run_controller::(client, context, "SonarrImportList", reconcile).await; } async fn reconcile(obj: Arc, ctx: Arc) -> Result { @@ -33,21 +28,33 @@ async fn reconcile(obj: Arc, ctx: Arc) -> Result, ctx: Arc) -> Result { let client = &ctx.client; - let namespace = import_list.namespace().ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + let namespace = import_list + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; let name = import_list.name_any(); info!("Reconciling SonarrImportList: {}/{}", namespace, name); - let config = get_sonarr_config(&ctx, client, &namespace, &import_list.spec.sonarr_instance_ref).await?; + let config = get_sonarr_config( + &ctx, + client, + &namespace, + &import_list.spec.sonarr_instance_ref, + ) + .await?; // Build import list resource let mut resource = ImportListResource::new(); resource.name = Some(Some(import_list.spec.name.clone())); - resource.implementation = Some(Some(get_implementation_name(&import_list.spec.list_type).to_string())); + resource.implementation = Some(Some( + get_implementation_name(&import_list.spec.list_type).to_string(), + )); resource.enable_automatic_add = Some(import_list.spec.enable_automatic_add); resource.search_for_missing_episodes = Some(import_list.spec.search_for_missing_episodes); resource.should_monitor = Some(convert_monitor_type(&import_list.spec.should_monitor)); - resource.monitor_new_items = Some(convert_new_item_monitor_type(&import_list.spec.monitor_new_items)); + resource.monitor_new_items = Some(convert_new_item_monitor_type( + &import_list.spec.monitor_new_items, + )); resource.root_folder_path = Some(Some(import_list.spec.root_folder_path.clone())); resource.quality_profile_id = Some(import_list.spec.quality_profile_id); resource.series_type = Some(convert_series_type(&import_list.spec.series_type)); @@ -57,7 +64,9 @@ async fn reconcile_apply(import_list: Arc, ctx: Arc) let sonarr_import_list = if let Some(id) = import_list.status.as_ref().and_then(|s| s.id) { resource.id = Some(id); - match import_list_api::update_import_list(&config, id, Some(false), Some(resource.clone())).await { + match import_list_api::update_import_list(&config, id, Some(false), Some(resource.clone())) + .await + { Ok(i) => i, Err(_) => { resource.id = None; @@ -66,7 +75,10 @@ async fn reconcile_apply(import_list: Arc, ctx: Arc) } } else { let existing = import_list_api::list_import_list(&config).await?; - if let Some(existing_item) = existing.iter().find(|i| i.name.as_ref().and_then(|n| n.as_ref()) == Some(&import_list.spec.name)) { + if let Some(existing_item) = existing + .iter() + .find(|i| i.name.as_ref().and_then(|n| n.as_ref()) == Some(&import_list.spec.name)) + { existing_item.clone() } else { import_list_api::create_import_list(&config, Some(false), Some(resource)).await? @@ -75,8 +87,15 @@ async fn reconcile_apply(import_list: Arc, ctx: Arc) // Update status let api: Api = Api::namespaced(client.clone(), &namespace); - let mut conditions = import_list.status.as_ref().map(|s| s.conditions.clone()).unwrap_or_default(); - update_conditions(&mut conditions, ready_condition(true, "Synced", "Import list synchronized with Sonarr")); + let mut conditions = import_list + .status + .as_ref() + .map(|s| s.conditions.clone()) + .unwrap_or_default(); + update_conditions( + &mut conditions, + ready_condition(true, "Synced", "Import list synchronized with Sonarr"), + ); let status = SonarrImportListStatus { conditions, @@ -85,7 +104,8 @@ async fn reconcile_apply(import_list: Arc, ctx: Arc) }; let status_patch = serde_json::json!({ "status": status }); - api.patch_status(&name, &PatchParams::default(), &Patch::Merge(&status_patch)).await?; + api.patch_status(&name, &PatchParams::default(), &Patch::Merge(&status_patch)) + .await?; Ok(Action::requeue(REQUEUE_DURATION)) } @@ -135,16 +155,31 @@ fn convert_series_type(st: &SeriesTypes) -> sonarr::models::SeriesTypes { } } -async fn reconcile_cleanup(import_list: Arc, ctx: Arc) -> Result { +async fn reconcile_cleanup( + import_list: Arc, + ctx: Arc, +) -> Result { let client = &ctx.client; - let namespace = import_list.namespace().ok_or(Error::MissingObjectKey(".metadata.namespace"))?; - - info!("Cleaning up SonarrImportList: {}/{}", namespace, import_list.name_any()); - - if let Some(id) = import_list.status.as_ref().and_then(|s| s.id) { - if let Ok(config) = get_sonarr_config(&ctx, client, &namespace, &import_list.spec.sonarr_instance_ref).await { - let _ = import_list_api::delete_import_list(&config, id).await; - } + let namespace = import_list + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + + info!( + "Cleaning up SonarrImportList: {}/{}", + namespace, + import_list.name_any() + ); + + if let Some(id) = import_list.status.as_ref().and_then(|s| s.id) + && let Ok(config) = get_sonarr_config( + &ctx, + client, + &namespace, + &import_list.spec.sonarr_instance_ref, + ) + .await + { + let _ = import_list_api::delete_import_list(&config, id).await; } Ok(Action::await_change()) diff --git a/src/controllers/indexer.rs b/src/controllers/indexer.rs index 5a35a7c..d32a9a5 100644 --- a/src/controllers/indexer.rs +++ b/src/controllers/indexer.rs @@ -9,23 +9,18 @@ use tracing::info; use sonarr::apis::indexer_api; use sonarr::models::IndexerResource; -use crate::crds::{SonarrIndexer, SonarrIndexerStatus}; +use crate::Context; use crate::crds::indexer::IndexerType; +use crate::crds::{SonarrIndexer, SonarrIndexerStatus}; use crate::error::{Error, Result}; -use crate::Context; -use super::traits::{run_controller, reconcile_with_finalizer, REQUEUE_DURATION}; use super::tag::get_sonarr_config; +use super::traits::{REQUEUE_DURATION, reconcile_with_finalizer, run_controller}; use super::{ready_condition, update_conditions}; /// Start the SonarrIndexer controller pub async fn run(client: Client, context: Arc) { - run_controller::( - client, - context, - "SonarrIndexer", - reconcile, - ).await; + run_controller::(client, context, "SonarrIndexer", reconcile).await; } async fn reconcile(obj: Arc, ctx: Arc) -> Result { @@ -34,12 +29,15 @@ async fn reconcile(obj: Arc, ctx: Arc) -> Result async fn reconcile_apply(indexer: Arc, ctx: Arc) -> Result { let client = &ctx.client; - let namespace = indexer.namespace().ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + let namespace = indexer + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; let name = indexer.name_any(); info!("Reconciling SonarrIndexer: {}/{}", namespace, name); - let config = get_sonarr_config(&ctx, client, &namespace, &indexer.spec.sonarr_instance_ref).await?; + let config = + get_sonarr_config(&ctx, client, &namespace, &indexer.spec.sonarr_instance_ref).await?; // Resolve API key from secret if needed // TODO: Use the api_key in indexer fields/config when setting up the indexer resource @@ -47,13 +45,16 @@ async fn reconcile_apply(indexer: Arc, ctx: Arc) -> Resu client, &namespace, indexer.spec.config.api_key.clone(), - indexer.spec.config.api_key_secret_ref.as_ref() - ).await?; + indexer.spec.config.api_key_secret_ref.as_ref(), + ) + .await?; // Build indexer resource let mut idx_resource = IndexerResource::new(); idx_resource.name = Some(Some(indexer.spec.name.clone())); - idx_resource.implementation = Some(Some(get_implementation_name(&indexer.spec.indexer_type).to_string())); + idx_resource.implementation = Some(Some( + get_implementation_name(&indexer.spec.indexer_type).to_string(), + )); idx_resource.enable_rss = Some(indexer.spec.enable_rss); idx_resource.enable_automatic_search = Some(indexer.spec.enable_automatic_search); idx_resource.enable_interactive_search = Some(indexer.spec.enable_interactive_search); @@ -64,7 +65,9 @@ async fn reconcile_apply(indexer: Arc, ctx: Arc) -> Resu let sonarr_indexer = if let Some(id) = indexer.status.as_ref().and_then(|s| s.id) { idx_resource.id = Some(id); - match indexer_api::update_indexer(&config, id, Some(false), Some(idx_resource.clone())).await { + match indexer_api::update_indexer(&config, id, Some(false), Some(idx_resource.clone())) + .await + { Ok(i) => i, Err(_) => { idx_resource.id = None; @@ -73,7 +76,10 @@ async fn reconcile_apply(indexer: Arc, ctx: Arc) -> Resu } } else { let existing = indexer_api::list_indexer(&config).await?; - if let Some(existing_idx) = existing.iter().find(|i| i.name.as_ref().and_then(|n| n.as_ref()) == Some(&indexer.spec.name)) { + if let Some(existing_idx) = existing + .iter() + .find(|i| i.name.as_ref().and_then(|n| n.as_ref()) == Some(&indexer.spec.name)) + { existing_idx.clone() } else { indexer_api::create_indexer(&config, Some(false), Some(idx_resource)).await? @@ -82,8 +88,15 @@ async fn reconcile_apply(indexer: Arc, ctx: Arc) -> Resu // Update status let indexers_api: Api = Api::namespaced(client.clone(), &namespace); - let mut conditions = indexer.status.as_ref().map(|s| s.conditions.clone()).unwrap_or_default(); - update_conditions(&mut conditions, ready_condition(true, "Synced", "Indexer synchronized with Sonarr")); + let mut conditions = indexer + .status + .as_ref() + .map(|s| s.conditions.clone()) + .unwrap_or_default(); + update_conditions( + &mut conditions, + ready_condition(true, "Synced", "Indexer synchronized with Sonarr"), + ); let status = SonarrIndexerStatus { conditions, @@ -92,7 +105,9 @@ async fn reconcile_apply(indexer: Arc, ctx: Arc) -> Resu }; let status_patch = serde_json::json!({ "status": status }); - indexers_api.patch_status(&name, &PatchParams::default(), &Patch::Merge(&status_patch)).await?; + indexers_api + .patch_status(&name, &PatchParams::default(), &Patch::Merge(&status_patch)) + .await?; Ok(Action::requeue(REQUEUE_DURATION)) } @@ -122,16 +137,20 @@ pub async fn resolve_secret_value( return Ok(Some(value)); } - if let Some(ref secret_ref) = secret_ref { + if let Some(secret_ref) = secret_ref { let secrets: Api = Api::namespaced(client.clone(), namespace); - let secret = secrets.get(&secret_ref.name).await + let secret = secrets + .get(&secret_ref.name) + .await .map_err(|_| Error::MissingApiCredentials)?; let data = secret.data.ok_or(Error::MissingApiCredentials)?; - let value_bytes = data.get(&secret_ref.key).ok_or(Error::MissingApiCredentials)?; + let value_bytes = data + .get(&secret_ref.key) + .ok_or(Error::MissingApiCredentials)?; - let value = String::from_utf8(value_bytes.0.clone()) - .map_err(|_| Error::MissingApiCredentials)?; + let value = + String::from_utf8(value_bytes.0.clone()).map_err(|_| Error::MissingApiCredentials)?; return Ok(Some(value)); } @@ -141,14 +160,21 @@ pub async fn resolve_secret_value( async fn reconcile_cleanup(indexer: Arc, ctx: Arc) -> Result { let client = &ctx.client; - let namespace = indexer.namespace().ok_or(Error::MissingObjectKey(".metadata.namespace"))?; - - info!("Cleaning up SonarrIndexer: {}/{}", namespace, indexer.name_any()); - - if let Some(id) = indexer.status.as_ref().and_then(|s| s.id) { - if let Ok(config) = get_sonarr_config(&ctx, client, &namespace, &indexer.spec.sonarr_instance_ref).await { - let _ = indexer_api::delete_indexer(&config, id).await; - } + let namespace = indexer + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + + info!( + "Cleaning up SonarrIndexer: {}/{}", + namespace, + indexer.name_any() + ); + + if let Some(id) = indexer.status.as_ref().and_then(|s| s.id) + && let Ok(config) = + get_sonarr_config(&ctx, client, &namespace, &indexer.spec.sonarr_instance_ref).await + { + let _ = indexer_api::delete_indexer(&config, id).await; } Ok(Action::await_change()) diff --git a/src/controllers/indexer_config.rs b/src/controllers/indexer_config.rs index 2d6f3bd..0b7feb2 100644 --- a/src/controllers/indexer_config.rs +++ b/src/controllers/indexer_config.rs @@ -23,13 +23,8 @@ use super::{ready_condition, update_conditions}; /// Start the SonarrIndexerConfig controller pub async fn run(client: Client, context: Arc) { - run_controller::( - client, - context, - "SonarrIndexerConfig", - reconcile, - ) - .await; + run_controller::(client, context, "SonarrIndexerConfig", reconcile) + .await; } async fn reconcile(obj: Arc, ctx: Arc) -> Result { diff --git a/src/controllers/language_profile.rs b/src/controllers/language_profile.rs index 12399ed..be247f8 100644 --- a/src/controllers/language_profile.rs +++ b/src/controllers/language_profile.rs @@ -194,12 +194,11 @@ async fn reconcile_cleanup( profile.name_any() ); - if let Some(id) = profile.status.as_ref().and_then(|s| s.id) { - if let Ok(config) = + if let Some(id) = profile.status.as_ref().and_then(|s| s.id) + && let Ok(config) = get_sonarr_config(&ctx, client, &namespace, &profile.spec.sonarr_instance_ref).await - { - let _ = language_profile_api::delete_language_profile(&config, id).await; - } + { + let _ = language_profile_api::delete_language_profile(&config, id).await; } Ok(Action::await_change()) diff --git a/src/controllers/media_management_config.rs b/src/controllers/media_management_config.rs index e3bef3b..d3bf558 100644 --- a/src/controllers/media_management_config.rs +++ b/src/controllers/media_management_config.rs @@ -18,9 +18,9 @@ use sonarr::models::{ use crate::Context; use crate::crds::media_management_config::{ - EpisodeTitleRequiredType as CrdEpisodeTitleRequiredType, - FileDateType as CrdFileDateType, ProperDownloadType, RescanAfterRefreshType as CrdRescanType, - SonarrMediaManagementConfig, SonarrMediaManagementConfigStatus, + EpisodeTitleRequiredType as CrdEpisodeTitleRequiredType, FileDateType as CrdFileDateType, + ProperDownloadType, RescanAfterRefreshType as CrdRescanType, SonarrMediaManagementConfig, + SonarrMediaManagementConfigStatus, }; use crate::error::{Error, Result}; @@ -39,10 +39,7 @@ pub async fn run(client: Client, context: Arc) { .await; } -async fn reconcile( - obj: Arc, - ctx: Arc, -) -> Result { +async fn reconcile(obj: Arc, ctx: Arc) -> Result { reconcile_with_finalizer(obj, ctx, reconcile_apply, reconcile_cleanup).await } @@ -261,10 +258,7 @@ async fn reconcile_apply( .copy_using_hardlinks .or(existing.copy_using_hardlinks); - resource.use_script_import = config - .spec - .use_script_import - .or(existing.use_script_import); + resource.use_script_import = config.spec.use_script_import.or(existing.use_script_import); resource.script_import_path = config .spec @@ -285,10 +279,7 @@ async fn reconcile_apply( .map(Some) .or(existing.extra_file_extensions); - resource.enable_media_info = config - .spec - .enable_media_info - .or(existing.enable_media_info); + resource.enable_media_info = config.spec.enable_media_info.or(existing.enable_media_info); // Update config let id = existing diff --git a/src/controllers/metadata.rs b/src/controllers/metadata.rs index dbad262..98057aa 100644 --- a/src/controllers/metadata.rs +++ b/src/controllers/metadata.rs @@ -8,23 +8,18 @@ use tracing::info; use sonarr::apis::metadata_api; use sonarr::models::MetadataResource; -use crate::crds::{SonarrMetadata, SonarrMetadataStatus}; +use crate::Context; use crate::crds::metadata::MetadataType; +use crate::crds::{SonarrMetadata, SonarrMetadataStatus}; use crate::error::{Error, Result}; -use crate::Context; -use super::traits::{run_controller, reconcile_with_finalizer, REQUEUE_DURATION}; use super::tag::get_sonarr_config; +use super::traits::{REQUEUE_DURATION, reconcile_with_finalizer, run_controller}; use super::{ready_condition, update_conditions}; /// Start the SonarrMetadata controller pub async fn run(client: Client, context: Arc) { - run_controller::( - client, - context, - "SonarrMetadata", - reconcile, - ).await; + run_controller::(client, context, "SonarrMetadata", reconcile).await; } async fn reconcile(obj: Arc, ctx: Arc) -> Result { @@ -33,18 +28,25 @@ async fn reconcile(obj: Arc, ctx: Arc) -> Result, ctx: Arc) -> Result { let client = &ctx.client; - let namespace = metadata.namespace().ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + let namespace = metadata + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; let name = metadata.name_any(); info!("Reconciling SonarrMetadata: {}/{}", namespace, name); - let config = get_sonarr_config(&ctx, client, &namespace, &metadata.spec.sonarr_instance_ref).await?; + let config = + get_sonarr_config(&ctx, client, &namespace, &metadata.spec.sonarr_instance_ref).await?; // Build metadata resource let mut resource = MetadataResource::new(); resource.name = Some(Some(metadata.spec.name.clone())); - resource.implementation = Some(Some(get_implementation_name(&metadata.spec.metadata_type).to_string())); - resource.config_contract = Some(Some(get_config_contract(&metadata.spec.metadata_type).to_string())); + resource.implementation = Some(Some( + get_implementation_name(&metadata.spec.metadata_type).to_string(), + )); + resource.config_contract = Some(Some( + get_config_contract(&metadata.spec.metadata_type).to_string(), + )); resource.enable = Some(metadata.spec.enable); resource.tags = Some(Some(metadata.spec.tags.clone())); @@ -52,7 +54,8 @@ async fn reconcile_apply(metadata: Arc, ctx: Arc) -> Re let sonarr_metadata = if let Some(id) = metadata.status.as_ref().and_then(|s| s.id) { resource.id = Some(id); - match metadata_api::update_metadata(&config, id, Some(false), Some(resource.clone())).await { + match metadata_api::update_metadata(&config, id, Some(false), Some(resource.clone())).await + { Ok(m) => m, Err(_) => { resource.id = None; @@ -61,7 +64,10 @@ async fn reconcile_apply(metadata: Arc, ctx: Arc) -> Re } } else { let existing = metadata_api::list_metadata(&config).await?; - if let Some(existing_item) = existing.iter().find(|m| m.name.as_ref().and_then(|n| n.as_ref()) == Some(&metadata.spec.name)) { + if let Some(existing_item) = existing + .iter() + .find(|m| m.name.as_ref().and_then(|n| n.as_ref()) == Some(&metadata.spec.name)) + { existing_item.clone() } else { metadata_api::create_metadata(&config, Some(false), Some(resource)).await? @@ -70,8 +76,15 @@ async fn reconcile_apply(metadata: Arc, ctx: Arc) -> Re // Update status let api: Api = Api::namespaced(client.clone(), &namespace); - let mut conditions = metadata.status.as_ref().map(|s| s.conditions.clone()).unwrap_or_default(); - update_conditions(&mut conditions, ready_condition(true, "Synced", "Metadata synchronized with Sonarr")); + let mut conditions = metadata + .status + .as_ref() + .map(|s| s.conditions.clone()) + .unwrap_or_default(); + update_conditions( + &mut conditions, + ready_condition(true, "Synced", "Metadata synchronized with Sonarr"), + ); let status = SonarrMetadataStatus { conditions, @@ -80,7 +93,8 @@ async fn reconcile_apply(metadata: Arc, ctx: Arc) -> Re }; let status_patch = serde_json::json!({ "status": status }); - api.patch_status(&name, &PatchParams::default(), &Patch::Merge(&status_patch)).await?; + api.patch_status(&name, &PatchParams::default(), &Patch::Merge(&status_patch)) + .await?; Ok(Action::requeue(REQUEUE_DURATION)) } @@ -103,14 +117,21 @@ fn get_config_contract(metadata_type: &MetadataType) -> &'static str { async fn reconcile_cleanup(metadata: Arc, ctx: Arc) -> Result { let client = &ctx.client; - let namespace = metadata.namespace().ok_or(Error::MissingObjectKey(".metadata.namespace"))?; - - info!("Cleaning up SonarrMetadata: {}/{}", namespace, metadata.name_any()); - - if let Some(id) = metadata.status.as_ref().and_then(|s| s.id) { - if let Ok(config) = get_sonarr_config(&ctx, client, &namespace, &metadata.spec.sonarr_instance_ref).await { - let _ = metadata_api::delete_metadata(&config, id).await; - } + let namespace = metadata + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + + info!( + "Cleaning up SonarrMetadata: {}/{}", + namespace, + metadata.name_any() + ); + + if let Some(id) = metadata.status.as_ref().and_then(|s| s.id) + && let Ok(config) = + get_sonarr_config(&ctx, client, &namespace, &metadata.spec.sonarr_instance_ref).await + { + let _ = metadata_api::delete_metadata(&config, id).await; } Ok(Action::await_change()) diff --git a/src/controllers/naming_config.rs b/src/controllers/naming_config.rs index 290944a..8e96a58 100644 --- a/src/controllers/naming_config.rs +++ b/src/controllers/naming_config.rs @@ -226,10 +226,7 @@ async fn reconcile_apply(config: Arc, ctx: Arc) -> Ok(Action::requeue(REQUEUE_DURATION)) } -async fn reconcile_cleanup( - _config: Arc, - _ctx: Arc, -) -> Result { +async fn reconcile_cleanup(_config: Arc, _ctx: Arc) -> Result { // Config settings persist in Sonarr, nothing to clean up Ok(Action::await_change()) } diff --git a/src/controllers/notification.rs b/src/controllers/notification.rs index bc1d810..7ae0f09 100644 --- a/src/controllers/notification.rs +++ b/src/controllers/notification.rs @@ -8,42 +8,51 @@ use tracing::info; use sonarr::apis::notification_api; use sonarr::models::NotificationResource; -use crate::crds::{SonarrNotification, SonarrNotificationStatus}; +use crate::Context; use crate::crds::notification::NotificationType; +use crate::crds::{SonarrNotification, SonarrNotificationStatus}; use crate::error::{Error, Result}; -use crate::Context; -use super::traits::{run_controller, reconcile_with_finalizer, REQUEUE_DURATION}; use super::tag::get_sonarr_config; +use super::traits::{REQUEUE_DURATION, reconcile_with_finalizer, run_controller}; use super::{ready_condition, update_conditions}; /// Start the SonarrNotification controller pub async fn run(client: Client, context: Arc) { - run_controller::( - client, - context, - "SonarrNotification", - reconcile, - ).await; + run_controller::(client, context, "SonarrNotification", reconcile) + .await; } async fn reconcile(obj: Arc, ctx: Arc) -> Result { reconcile_with_finalizer(obj, ctx, reconcile_apply, reconcile_cleanup).await } -async fn reconcile_apply(notification: Arc, ctx: Arc) -> Result { +async fn reconcile_apply( + notification: Arc, + ctx: Arc, +) -> Result { let client = &ctx.client; - let namespace = notification.namespace().ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + let namespace = notification + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; let name = notification.name_any(); info!("Reconciling SonarrNotification: {}/{}", namespace, name); - let config = get_sonarr_config(&ctx, client, &namespace, ¬ification.spec.sonarr_instance_ref).await?; + let config = get_sonarr_config( + &ctx, + client, + &namespace, + ¬ification.spec.sonarr_instance_ref, + ) + .await?; // Build notification resource let mut n_resource = NotificationResource::new(); n_resource.name = Some(Some(notification.spec.name.clone())); - n_resource.implementation = Some(Some(get_implementation_name(¬ification.spec.notification_type).to_string())); + n_resource.implementation = Some(Some( + get_implementation_name(¬ification.spec.notification_type).to_string(), + )); n_resource.on_grab = Some(notification.spec.triggers.on_grab); n_resource.on_download = Some(notification.spec.triggers.on_download); n_resource.on_upgrade = Some(notification.spec.triggers.on_upgrade); @@ -51,26 +60,43 @@ async fn reconcile_apply(notification: Arc, ctx: Arc n, Err(_) => { n_resource.id = None; - notification_api::create_notification(&config, Some(false), Some(n_resource)).await? + notification_api::create_notification(&config, Some(false), Some(n_resource)) + .await? } } } else { let existing = notification_api::list_notification(&config).await?; - if let Some(existing_n) = existing.iter().find(|n| n.name.as_ref().and_then(|nm| nm.as_ref()) == Some(¬ification.spec.name)) { + if let Some(existing_n) = existing + .iter() + .find(|n| n.name.as_ref().and_then(|nm| nm.as_ref()) == Some(¬ification.spec.name)) + { existing_n.clone() } else { notification_api::create_notification(&config, Some(false), Some(n_resource)).await? @@ -79,8 +105,15 @@ async fn reconcile_apply(notification: Arc, ctx: Arc = Api::namespaced(client.clone(), &namespace); - let mut conditions = notification.status.as_ref().map(|s| s.conditions.clone()).unwrap_or_default(); - update_conditions(&mut conditions, ready_condition(true, "Synced", "Notification synchronized with Sonarr")); + let mut conditions = notification + .status + .as_ref() + .map(|s| s.conditions.clone()) + .unwrap_or_default(); + update_conditions( + &mut conditions, + ready_condition(true, "Synced", "Notification synchronized with Sonarr"), + ); let status = SonarrNotificationStatus { conditions, @@ -89,7 +122,9 @@ async fn reconcile_apply(notification: Arc, ctx: Arc &'static str } } -async fn reconcile_cleanup(notification: Arc, ctx: Arc) -> Result { +async fn reconcile_cleanup( + notification: Arc, + ctx: Arc, +) -> Result { let client = &ctx.client; - let namespace = notification.namespace().ok_or(Error::MissingObjectKey(".metadata.namespace"))?; - - info!("Cleaning up SonarrNotification: {}/{}", namespace, notification.name_any()); - - if let Some(id) = notification.status.as_ref().and_then(|s| s.id) { - if let Ok(config) = get_sonarr_config(&ctx, client, &namespace, ¬ification.spec.sonarr_instance_ref).await { - let _ = notification_api::delete_notification(&config, id).await; - } + let namespace = notification + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + + info!( + "Cleaning up SonarrNotification: {}/{}", + namespace, + notification.name_any() + ); + + if let Some(id) = notification.status.as_ref().and_then(|s| s.id) + && let Ok(config) = get_sonarr_config( + &ctx, + client, + &namespace, + ¬ification.spec.sonarr_instance_ref, + ) + .await + { + let _ = notification_api::delete_notification(&config, id).await; } Ok(Action::await_change()) diff --git a/src/controllers/quality_profile.rs b/src/controllers/quality_profile.rs index 916dfbb..ae1f01e 100644 --- a/src/controllers/quality_profile.rs +++ b/src/controllers/quality_profile.rs @@ -120,12 +120,11 @@ async fn reconcile_cleanup( profile.name_any() ); - if let Some(id) = profile.status.as_ref().and_then(|s| s.id) { - if let Ok(config) = + if let Some(id) = profile.status.as_ref().and_then(|s| s.id) + && let Ok(config) = get_sonarr_config(&ctx, client, &namespace, &profile.spec.sonarr_instance_ref).await - { - let _ = quality_profile_api::delete_quality_profile(&config, id).await; - } + { + let _ = quality_profile_api::delete_quality_profile(&config, id).await; } Ok(Action::await_change()) diff --git a/src/controllers/root_folder.rs b/src/controllers/root_folder.rs index c1e211a..eee08ba 100644 --- a/src/controllers/root_folder.rs +++ b/src/controllers/root_folder.rs @@ -101,12 +101,11 @@ async fn reconcile_cleanup(folder: Arc, ctx: Arc) -> folder.name_any() ); - if let Some(id) = folder.status.as_ref().and_then(|s| s.id) { - if let Ok(config) = + if let Some(id) = folder.status.as_ref().and_then(|s| s.id) + && let Ok(config) = get_sonarr_config(&ctx, client, &namespace, &folder.spec.sonarr_instance_ref).await - { - let _ = root_folder_api::delete_root_folder(&config, id).await; - } + { + let _ = root_folder_api::delete_root_folder(&config, id).await; } Ok(Action::await_change()) diff --git a/src/controllers/series.rs b/src/controllers/series.rs index 4776403..5a99672 100644 --- a/src/controllers/series.rs +++ b/src/controllers/series.rs @@ -8,23 +8,18 @@ use tracing::{info, warn}; use sonarr::apis::{series_api, series_lookup_api}; use sonarr::models::{AddSeriesOptions, MonitorTypes, SeriesTypes}; -use crate::crds::{SonarrSeries, SonarrSeriesStatus}; +use crate::Context; use crate::crds::series::{MonitorType, SeriesType}; +use crate::crds::{SonarrSeries, SonarrSeriesStatus}; use crate::error::{Error, Result}; -use crate::Context; -use super::traits::{run_controller, reconcile_with_finalizer, REQUEUE_DURATION}; use super::tag::get_sonarr_config; +use super::traits::{REQUEUE_DURATION, reconcile_with_finalizer, run_controller}; use super::{ready_condition, update_conditions}; /// Start the SonarrSeries controller pub async fn run(client: Client, context: Arc) { - run_controller::( - client, - context, - "SonarrSeries", - reconcile, - ).await; + run_controller::(client, context, "SonarrSeries", reconcile).await; } async fn reconcile(obj: Arc, ctx: Arc) -> Result { @@ -33,23 +28,28 @@ async fn reconcile(obj: Arc, ctx: Arc) -> Result async fn reconcile_apply(series: Arc, ctx: Arc) -> Result { let client = &ctx.client; - let namespace = series.namespace().ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + let namespace = series + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; let name = series.name_any(); info!("Reconciling SonarrSeries: {}/{}", namespace, name); - let config = get_sonarr_config(&ctx, client, &namespace, &series.spec.sonarr_instance_ref).await?; + let config = + get_sonarr_config(&ctx, client, &namespace, &series.spec.sonarr_instance_ref).await?; // Check if series exists by ID or by TVDB ID let existing_series = if let Some(id) = series.status.as_ref().and_then(|s| s.id) { - match series_api::get_series_by_id(&config, id, Some(false)).await { - Ok(s) => Some(s), - Err(_) => None, - } + series_api::get_series_by_id(&config, id, Some(false)) + .await + .ok() } else { // Try to find by TVDB ID - let all_series = series_api::list_series(&config, Some(series.spec.tvdb_id), Some(true)).await?; - all_series.into_iter().find(|s| s.tvdb_id == Some(series.spec.tvdb_id)) + let all_series = + series_api::list_series(&config, Some(series.spec.tvdb_id), Some(true)).await?; + all_series + .into_iter() + .find(|s| s.tvdb_id == Some(series.spec.tvdb_id)) }; let sonarr_series = if let Some(mut existing) = existing_series { @@ -64,13 +64,26 @@ async fn reconcile_apply(series: Arc, ctx: Arc) -> Result existing.tags = Some(Some(series.spec.tags.clone())); existing.series_type = Some(convert_series_type(&series.spec.series_type)); - series_api::update_series(&config, existing.id.unwrap_or(0).to_string().as_str(), Some(false), Some(existing)).await? + series_api::update_series( + &config, + existing.id.unwrap_or(0).to_string().as_str(), + Some(false), + Some(existing), + ) + .await? } else { // Need to lookup series from TVDB first - let lookup_results = series_lookup_api::list_series_lookup(&config, Some(&format!("tvdb:{}", series.spec.tvdb_id))).await?; + let lookup_results = series_lookup_api::list_series_lookup( + &config, + Some(&format!("tvdb:{}", series.spec.tvdb_id)), + ) + .await?; if lookup_results.is_empty() { - return Err(Error::Other(format!("Series with TVDB ID {} not found", series.spec.tvdb_id))); + return Err(Error::Other(format!( + "Series with TVDB ID {} not found", + series.spec.tvdb_id + ))); } let mut new_series = lookup_results.into_iter().next().unwrap(); @@ -87,8 +100,10 @@ async fn reconcile_apply(series: Arc, ctx: Arc) -> Result // Set add options let mut add_options = AddSeriesOptions::new(); add_options.monitor = Some(convert_monitor_type(&series.spec.add_options.monitor)); - add_options.search_for_missing_episodes = Some(series.spec.add_options.search_for_missing_episodes); - add_options.search_for_cutoff_unmet_episodes = Some(series.spec.add_options.search_for_cutoff_unmet_episodes); + add_options.search_for_missing_episodes = + Some(series.spec.add_options.search_for_missing_episodes); + add_options.search_for_cutoff_unmet_episodes = + Some(series.spec.add_options.search_for_cutoff_unmet_episodes); new_series.add_options = Some(Box::new(add_options)); @@ -97,8 +112,15 @@ async fn reconcile_apply(series: Arc, ctx: Arc) -> Result // Update status let series_api_k8s: Api = Api::namespaced(client.clone(), &namespace); - let mut conditions = series.status.as_ref().map(|s| s.conditions.clone()).unwrap_or_default(); - update_conditions(&mut conditions, ready_condition(true, "Synced", "Series synchronized with Sonarr")); + let mut conditions = series + .status + .as_ref() + .map(|s| s.conditions.clone()) + .unwrap_or_default(); + update_conditions( + &mut conditions, + ready_condition(true, "Synced", "Series synchronized with Sonarr"), + ); // Extract statistics from the series let stats = sonarr_series.statistics.as_ref(); @@ -111,13 +133,18 @@ async fn reconcile_apply(series: Arc, ctx: Arc) -> Result episode_file_count: stats.and_then(|s| s.episode_file_count), percent_complete: stats.and_then(|s| s.percent_of_episodes), next_airing: sonarr_series.next_airing.flatten().map(|d| d.to_string()), - previous_airing: sonarr_series.previous_airing.flatten().map(|d| d.to_string()), + previous_airing: sonarr_series + .previous_airing + .flatten() + .map(|d| d.to_string()), network: sonarr_series.network.flatten(), series_status: sonarr_series.status.map(|s| format!("{:?}", s)), }; let status_patch = serde_json::json!({ "status": status }); - series_api_k8s.patch_status(&name, &PatchParams::default(), &Patch::Merge(&status_patch)).await?; + series_api_k8s + .patch_status(&name, &PatchParams::default(), &Patch::Merge(&status_patch)) + .await?; Ok(Action::requeue(REQUEUE_DURATION)) } @@ -146,16 +173,23 @@ fn convert_series_type(series_type: &SeriesType) -> SeriesTypes { async fn reconcile_cleanup(series: Arc, ctx: Arc) -> Result { let client = &ctx.client; - let namespace = series.namespace().ok_or(Error::MissingObjectKey(".metadata.namespace"))?; - - info!("Cleaning up SonarrSeries: {}/{}", namespace, series.name_any()); - - if let Some(id) = series.status.as_ref().and_then(|s| s.id) { - if let Ok(config) = get_sonarr_config(&ctx, client, &namespace, &series.spec.sonarr_instance_ref).await { - // Default to not deleting files on cleanup - warn!("Deleting series {} from Sonarr", id); - let _ = series_api::delete_series(&config, id, Some(false), Some(false)).await; - } + let namespace = series + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + + info!( + "Cleaning up SonarrSeries: {}/{}", + namespace, + series.name_any() + ); + + if let Some(id) = series.status.as_ref().and_then(|s| s.id) + && let Ok(config) = + get_sonarr_config(&ctx, client, &namespace, &series.spec.sonarr_instance_ref).await + { + // Default to not deleting files on cleanup + warn!("Deleting series {} from Sonarr", id); + let _ = series_api::delete_series(&config, id, Some(false), Some(false)).await; } Ok(Action::await_change()) diff --git a/src/controllers/sonarr.rs b/src/controllers/sonarr.rs index 9fa25d3..1d6c9a6 100644 --- a/src/controllers/sonarr.rs +++ b/src/controllers/sonarr.rs @@ -104,33 +104,33 @@ async fn reconcile_apply(instance: Arc, ctx: Arc) -> Result, ctx: Arc) -> Result(resource_name: &'static str) -> impl Fn(Arc, &Error, Arc) -> Action +pub fn error_policy( + resource_name: &'static str, +) -> impl Fn(Arc, &Error, Arc) -> Action where R: Resource + ResourceExt, { move |obj: Arc, err: &Error, _ctx: Arc| { - error!("Error reconciling {} {}: {:?}", resource_name, obj.name_any(), err); + error!( + "Error reconciling {} {}: {:?}", + resource_name, + obj.name_any(), + err + ); Action::requeue(Duration::from_secs(60)) } } @@ -96,13 +103,7 @@ pub async fn run_controller( resource_name: &'static str, reconcile_fn: ReconcileFn, ) where - R: Resource - + Clone - + Debug - + DeserializeOwned - + Send - + Sync - + 'static, + R: Resource + Clone + Debug + DeserializeOwned + Send + Sync + 'static, R::DynamicType: Default + Eq + Hash + Clone, ReconcileFn: FnMut(Arc, Arc) -> ReconcileFut + Send + Sync + 'static + Clone, ReconcileFut: Future> + Send + 'static, @@ -135,7 +136,11 @@ pub async fn update_status_success( existing_conditions: Vec, ) -> Result<()> where - R: Resource + Clone + Debug + DeserializeOwned + Serialize, + R: Resource + + Clone + + Debug + + DeserializeOwned + + Serialize, R: Resource, { let api: Api = Api::namespaced(client.clone(), namespace); @@ -153,11 +158,21 @@ where } }); - api.patch_status(name, &PatchParams::apply("sonarr-operator"), &Patch::Merge(&status)) - .await - .map_err(|e| Error::KubeError(e))?; - - info!("Updated {} {}/{} status with id={}", std::any::type_name::(), namespace, name, sonarr_id); + api.patch_status( + name, + &PatchParams::apply("sonarr-operator"), + &Patch::Merge(&status), + ) + .await + .map_err(Error::KubeError)?; + + info!( + "Updated {} {}/{} status with id={}", + std::any::type_name::(), + namespace, + name, + sonarr_id + ); Ok(()) } @@ -170,7 +185,11 @@ pub async fn update_status_failure( existing_conditions: Vec, ) -> Result<()> where - R: Resource + Clone + Debug + DeserializeOwned + Serialize, + R: Resource + + Clone + + Debug + + DeserializeOwned + + Serialize, R: Resource, { let api: Api = Api::namespaced(client.clone(), namespace); @@ -186,9 +205,13 @@ where } }); - api.patch_status(name, &PatchParams::apply("sonarr-operator"), &Patch::Merge(&status)) - .await - .map_err(|e| Error::KubeError(e))?; + api.patch_status( + name, + &PatchParams::apply("sonarr-operator"), + &Patch::Merge(&status), + ) + .await + .map_err(Error::KubeError)?; Ok(()) } diff --git a/src/controllers/utils.rs b/src/controllers/utils.rs index 1ed90da..fe9e304 100644 --- a/src/controllers/utils.rs +++ b/src/controllers/utils.rs @@ -1,9 +1,7 @@ use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; fn now() -> k8s_openapi::apimachinery::pkg::apis::meta::v1::Time { - k8s_openapi::apimachinery::pkg::apis::meta::v1::Time( - k8s_openapi::jiff::Timestamp::now() - ) + k8s_openapi::apimachinery::pkg::apis::meta::v1::Time(k8s_openapi::jiff::Timestamp::now()) } /// Create a Ready condition with the given status and message @@ -44,7 +42,10 @@ pub fn synced_condition(status: bool, reason: &str, message: &str) -> Condition /// Update conditions list, replacing existing conditions of the same type pub fn update_conditions(conditions: &mut Vec, new_condition: Condition) { - if let Some(existing) = conditions.iter_mut().find(|c| c.type_ == new_condition.type_) { + if let Some(existing) = conditions + .iter_mut() + .find(|c| c.type_ == new_condition.type_) + { if existing.status != new_condition.status || existing.reason != new_condition.reason { *existing = new_condition; } diff --git a/src/crds/notification.rs b/src/crds/notification.rs index 0ae207b..2351a52 100644 --- a/src/crds/notification.rs +++ b/src/crds/notification.rs @@ -3,8 +3,8 @@ use kube::CustomResource; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use super::SonarrInstanceRef; use super::SecretKeySelector; +use super::SonarrInstanceRef; /// SonarrNotification represents a notification/connect configuration in Sonarr /// Notifications are used to alert on events (Discord, Telegram, Webhook, etc.) diff --git a/src/lib.rs b/src/lib.rs index 3a3a5ac..93ae23f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,9 +10,9 @@ pub mod error; pub use error::{Error, Result}; -use std::sync::Arc; -use kube::Client; use crate::api::SonarrClientFactory; +use kube::Client; +use std::sync::Arc; /// Shared context for all controllers pub struct Context { diff --git a/tests/e2e/scenarios/config_resources.rs b/tests/e2e/scenarios/config_resources.rs index a53800f..f2ae720 100644 --- a/tests/e2e/scenarios/config_resources.rs +++ b/tests/e2e/scenarios/config_resources.rs @@ -12,21 +12,33 @@ use std::time::Duration; #[tokio::test] #[ignore = "requires E2E environment - run with: cargo test --test e2e -- --ignored"] async fn test_media_management_config_update() { - let mut ctx = TestContext::new().await.expect("Failed to create test context"); - setup_e2e_namespace(&ctx.client).await.expect("Failed to setup namespace"); + let mut ctx = TestContext::new() + .await + .expect("Failed to create test context"); + setup_e2e_namespace(&ctx.client) + .await + .expect("Failed to setup namespace"); // Get current config from Sonarr for comparison - let original_config = ctx.sonarr + let original_config = ctx + .sonarr .get_media_management_config() .await .expect("Failed to get current media management config"); - tracing::info!("Original config: recycle_bin_cleanup_days = {}", original_config.recycle_bin_cleanup_days); + tracing::info!( + "Original config: recycle_bin_cleanup_days = {}", + original_config.recycle_bin_cleanup_days + ); // Create MediaManagementConfig CR let config_name = unique_name("e2e-mmc"); ctx.register_cleanup("SonarrMediaManagementConfig", E2E_NAMESPACE, &config_name); - let new_cleanup_days = if original_config.recycle_bin_cleanup_days == 7 { 14 } else { 7 }; + let new_cleanup_days = if original_config.recycle_bin_cleanup_days == 7 { + 14 + } else { + 7 + }; let config = SonarrMediaManagementConfig { metadata: ObjectMeta { @@ -47,7 +59,9 @@ async fn test_media_management_config_update() { status: None, }; - apply_resource(&ctx.client, &config).await.expect("Failed to create config"); + apply_resource(&ctx.client, &config) + .await + .expect("Failed to create config"); // Wait for Ready wait_for_ready::( @@ -63,7 +77,8 @@ async fn test_media_management_config_update() { tokio::time::sleep(Duration::from_secs(3)).await; // Verify in Sonarr - let updated_config = ctx.sonarr + let updated_config = ctx + .sonarr .get_media_management_config() .await .expect("Failed to get updated config"); @@ -72,8 +87,14 @@ async fn test_media_management_config_update() { updated_config.recycle_bin_cleanup_days, new_cleanup_days, "Recycle bin cleanup days should be updated" ); - assert!(updated_config.create_empty_series_folders, "Create empty folders should be true"); - assert!(updated_config.delete_empty_folders, "Delete empty folders should be true"); + assert!( + updated_config.create_empty_series_folders, + "Create empty folders should be true" + ); + assert!( + updated_config.delete_empty_folders, + "Delete empty folders should be true" + ); tracing::info!("✓ MediaManagementConfig verified in Sonarr"); // Restore original value @@ -112,15 +133,23 @@ async fn test_media_management_config_update() { #[tokio::test] #[ignore = "requires E2E environment - run with: cargo test --test e2e -- --ignored"] async fn test_naming_config_update() { - let mut ctx = TestContext::new().await.expect("Failed to create test context"); - setup_e2e_namespace(&ctx.client).await.expect("Failed to setup namespace"); + let mut ctx = TestContext::new() + .await + .expect("Failed to create test context"); + setup_e2e_namespace(&ctx.client) + .await + .expect("Failed to setup namespace"); // Get current config - let original_config = ctx.sonarr + let original_config = ctx + .sonarr .get_naming_config() .await .expect("Failed to get current naming config"); - tracing::info!("Original config: rename_episodes = {}", original_config.rename_episodes); + tracing::info!( + "Original config: rename_episodes = {}", + original_config.rename_episodes + ); let config_name = unique_name("e2e-nc"); ctx.register_cleanup("SonarrNamingConfig", E2E_NAMESPACE, &config_name); @@ -135,7 +164,9 @@ async fn test_naming_config_update() { spec: SonarrNamingConfigSpec { rename_episodes: Some(true), replace_illegal_characters: Some(true), - standard_episode_format: Some("{Series Title} - S{season:00}E{episode:00} - {Episode Title}".to_string()), + standard_episode_format: Some( + "{Series Title} - S{season:00}E{episode:00} - {Episode Title}".to_string(), + ), season_folder_format: Some("Season {season}".to_string()), sonarr_instance_ref: SonarrInstanceRef { name: "sonarr".to_string(), @@ -146,26 +177,30 @@ async fn test_naming_config_update() { status: None, }; - apply_resource(&ctx.client, &config).await.expect("Failed to create naming config"); + apply_resource(&ctx.client, &config) + .await + .expect("Failed to create naming config"); - wait_for_ready::( - &ctx.client, - E2E_NAMESPACE, - &config_name, - E2E_TIMEOUT, - ) - .await - .expect("NamingConfig never became ready"); + wait_for_ready::(&ctx.client, E2E_NAMESPACE, &config_name, E2E_TIMEOUT) + .await + .expect("NamingConfig never became ready"); tokio::time::sleep(Duration::from_secs(3)).await; - let updated_config = ctx.sonarr + let updated_config = ctx + .sonarr .get_naming_config() .await .expect("Failed to get updated naming config"); - assert!(updated_config.rename_episodes, "Rename episodes should be enabled"); - assert!(updated_config.replace_illegal_characters, "Replace illegal chars should be enabled"); + assert!( + updated_config.rename_episodes, + "Rename episodes should be enabled" + ); + assert!( + updated_config.replace_illegal_characters, + "Replace illegal chars should be enabled" + ); tracing::info!("✓ NamingConfig verified in Sonarr"); ctx.cleanup().await; @@ -175,8 +210,12 @@ async fn test_naming_config_update() { #[tokio::test] #[ignore = "requires E2E environment - run with: cargo test --test e2e -- --ignored"] async fn test_config_singleton_constraint() { - let mut ctx = TestContext::new().await.expect("Failed to create test context"); - setup_e2e_namespace(&ctx.client).await.expect("Failed to setup namespace"); + let mut ctx = TestContext::new() + .await + .expect("Failed to create test context"); + setup_e2e_namespace(&ctx.client) + .await + .expect("Failed to setup namespace"); // Create first MediaManagementConfig let config1_name = unique_name("e2e-mmc-1"); @@ -199,7 +238,9 @@ async fn test_config_singleton_constraint() { status: None, }; - apply_resource(&ctx.client, &config1).await.expect("Failed to create first config"); + apply_resource(&ctx.client, &config1) + .await + .expect("Failed to create first config"); wait_for_ready::( &ctx.client, @@ -233,26 +274,35 @@ async fn test_config_singleton_constraint() { status: None, }; - apply_resource(&ctx.client, &config2).await.expect("Failed to create second config CR"); + apply_resource(&ctx.client, &config2) + .await + .expect("Failed to create second config CR"); // Wait a bit for operator to process tokio::time::sleep(Duration::from_secs(10)).await; // Check that second config has a conflict/error condition let api: Api = Api::namespaced(ctx.client.clone(), E2E_NAMESPACE); - let config2_status = api.get(&config2_name).await.expect("Failed to get second config"); + let config2_status = api + .get(&config2_name) + .await + .expect("Failed to get second config"); if let Some(status) = &config2_status.status { - let has_conflict = status.conditions.iter().any(|c| { - (c.type_ == "Ready" && c.status == "False") || - c.type_ == "Conflict" - }); + let has_conflict = status + .conditions + .iter() + .any(|c| (c.type_ == "Ready" && c.status == "False") || c.type_ == "Conflict"); if has_conflict { - tracing::info!("✓ Singleton constraint enforced - second config has error/conflict condition"); + tracing::info!( + "✓ Singleton constraint enforced - second config has error/conflict condition" + ); } else { // The operator might handle this differently - tracing::warn!("Second config doesn't show conflict - singleton may not be enforced at controller level"); + tracing::warn!( + "Second config doesn't show conflict - singleton may not be enforced at controller level" + ); } } diff --git a/tests/e2e/scenarios/custom_format_lifecycle.rs b/tests/e2e/scenarios/custom_format_lifecycle.rs index da649f3..fdf3776 100644 --- a/tests/e2e/scenarios/custom_format_lifecycle.rs +++ b/tests/e2e/scenarios/custom_format_lifecycle.rs @@ -10,8 +10,12 @@ use std::time::Duration; #[tokio::test] #[ignore = "requires E2E environment - run with: cargo test --test e2e -- --ignored"] async fn test_custom_format_full_lifecycle() { - let mut ctx = TestContext::new().await.expect("Failed to create test context"); - setup_e2e_namespace(&ctx.client).await.expect("Failed to setup namespace"); + let mut ctx = TestContext::new() + .await + .expect("Failed to create test context"); + setup_e2e_namespace(&ctx.client) + .await + .expect("Failed to setup namespace"); let cf_name = unique_name("e2e-cf"); let format_name = format!("E2E Custom Format {}", cf_name); @@ -38,18 +42,16 @@ async fn test_custom_format_full_lifecycle() { status: None, }; - apply_resource(&ctx.client, &custom_format).await.expect("Failed to create custom format CR"); + apply_resource(&ctx.client, &custom_format) + .await + .expect("Failed to create custom format CR"); // Wait for Ready condition tracing::info!("Waiting for custom format to be ready..."); - let ready_cf = wait_for_ready::( - &ctx.client, - E2E_NAMESPACE, - &cf_name, - E2E_TIMEOUT, - ) - .await - .expect("Custom format never became ready"); + let ready_cf = + wait_for_ready::(&ctx.client, E2E_NAMESPACE, &cf_name, E2E_TIMEOUT) + .await + .expect("Custom format never became ready"); let cf_id = ready_cf .status @@ -60,7 +62,8 @@ async fn test_custom_format_full_lifecycle() { // Verify in Sonarr API tracing::info!("Verifying custom format exists in Sonarr..."); - let sonarr_cf = ctx.sonarr + let sonarr_cf = ctx + .sonarr .find_custom_format_by_name(&format_name) .await .expect("Failed to query Sonarr API") diff --git a/tests/e2e/scenarios/download_client_config_lifecycle.rs b/tests/e2e/scenarios/download_client_config_lifecycle.rs index 6f794db..1942e45 100644 --- a/tests/e2e/scenarios/download_client_config_lifecycle.rs +++ b/tests/e2e/scenarios/download_client_config_lifecycle.rs @@ -216,7 +216,9 @@ async fn test_download_client_config_singleton_constraint() { "✓ Singleton constraint enforced - second DownloadClientConfig has error/conflict condition" ); } else { - tracing::warn!("Second config doesn't show conflict - singleton may not be enforced at controller level"); + tracing::warn!( + "Second config doesn't show conflict - singleton may not be enforced at controller level" + ); } } diff --git a/tests/e2e/scenarios/quality_profile_lifecycle.rs b/tests/e2e/scenarios/quality_profile_lifecycle.rs index d05af9d..7ee1111 100644 --- a/tests/e2e/scenarios/quality_profile_lifecycle.rs +++ b/tests/e2e/scenarios/quality_profile_lifecycle.rs @@ -3,16 +3,20 @@ use crate::common::*; use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; use kube::api::{Api, DeleteParams, Patch, PatchParams}; +use sonarr_operator::crds::quality_profile::{Quality, QualityGroup}; use sonarr_operator::crds::{SonarrInstanceRef, SonarrQualityProfile, SonarrQualityProfileSpec}; -use sonarr_operator::crds::quality_profile::{QualityGroup, Quality}; use std::time::Duration; /// Test the full lifecycle of a QualityProfile #[tokio::test] #[ignore = "requires E2E environment - run with: cargo test --test e2e -- --ignored"] async fn test_quality_profile_full_lifecycle() { - let mut ctx = TestContext::new().await.expect("Failed to create test context"); - setup_e2e_namespace(&ctx.client).await.expect("Failed to setup namespace"); + let mut ctx = TestContext::new() + .await + .expect("Failed to create test context"); + setup_e2e_namespace(&ctx.client) + .await + .expect("Failed to setup namespace"); let qp_name = unique_name("e2e-qp"); let profile_name = format!("E2E Test Profile {}", qp_name); @@ -31,20 +35,16 @@ async fn test_quality_profile_full_lifecycle() { name: profile_name.clone(), upgrade_allowed: true, cutoff: 7, // Bluray-1080p - quality_groups: vec![ - QualityGroup { - id: None, - name: Some("HD".to_string()), - qualities: vec![ - Quality { - id: Some(7), - name: Some("Bluray-1080p".to_string()), - source: None, - resolution: None, - }, - ], - }, - ], + quality_groups: vec![QualityGroup { + id: None, + name: Some("HD".to_string()), + qualities: vec![Quality { + id: Some(7), + name: Some("Bluray-1080p".to_string()), + source: None, + resolution: None, + }], + }], sonarr_instance_ref: SonarrInstanceRef { name: "sonarr".to_string(), namespace: Some("default".to_string()), @@ -57,18 +57,16 @@ async fn test_quality_profile_full_lifecycle() { status: None, }; - apply_resource(&ctx.client, &quality_profile).await.expect("Failed to create quality profile CR"); + apply_resource(&ctx.client, &quality_profile) + .await + .expect("Failed to create quality profile CR"); // Wait for Ready condition tracing::info!("Waiting for quality profile to be ready..."); - let ready_qp = wait_for_ready::( - &ctx.client, - E2E_NAMESPACE, - &qp_name, - E2E_TIMEOUT, - ) - .await - .expect("Quality profile never became ready"); + let ready_qp = + wait_for_ready::(&ctx.client, E2E_NAMESPACE, &qp_name, E2E_TIMEOUT) + .await + .expect("Quality profile never became ready"); let qp_id = ready_qp .status @@ -79,14 +77,18 @@ async fn test_quality_profile_full_lifecycle() { // Verify in Sonarr API tracing::info!("Verifying quality profile exists in Sonarr..."); - let sonarr_qp = ctx.sonarr + let sonarr_qp = ctx + .sonarr .find_quality_profile_by_name(&profile_name) .await .expect("Failed to query Sonarr API") .expect("Quality profile not found in Sonarr"); assert_eq!(sonarr_qp.id, qp_id, "Quality profile ID mismatch"); - assert_eq!(sonarr_qp.name, profile_name, "Quality profile name mismatch"); + assert_eq!( + sonarr_qp.name, profile_name, + "Quality profile name mismatch" + ); assert!(sonarr_qp.upgrade_allowed, "Upgrade should be allowed"); tracing::info!("✓ Quality profile verified in Sonarr"); @@ -128,14 +130,18 @@ async fn test_quality_profile_full_lifecycle() { tokio::time::sleep(Duration::from_secs(5)).await; // Verify update in Sonarr - let updated_sonarr_qp = ctx.sonarr + let updated_sonarr_qp = ctx + .sonarr .find_quality_profile_by_name(&updated_profile_name) .await .expect("Failed to query Sonarr API") .expect("Updated quality profile not found in Sonarr"); assert_eq!(updated_sonarr_qp.id, qp_id, "ID should not change"); - assert!(!updated_sonarr_qp.upgrade_allowed, "Upgrade should be disabled"); + assert!( + !updated_sonarr_qp.upgrade_allowed, + "Upgrade should be disabled" + ); tracing::info!("✓ Quality profile update verified in Sonarr"); // Delete and verify cleanup @@ -149,7 +155,10 @@ async fn test_quality_profile_full_lifecycle() { tokio::time::sleep(Duration::from_secs(3)).await; - let deleted = ctx.sonarr.find_quality_profile_by_name(&updated_profile_name).await; + let deleted = ctx + .sonarr + .find_quality_profile_by_name(&updated_profile_name) + .await; assert!( matches!(deleted, Ok(None)), "Quality profile should be deleted from Sonarr" diff --git a/tests/e2e/scenarios/root_folder_lifecycle.rs b/tests/e2e/scenarios/root_folder_lifecycle.rs index deb0f8d..205c79a 100644 --- a/tests/e2e/scenarios/root_folder_lifecycle.rs +++ b/tests/e2e/scenarios/root_folder_lifecycle.rs @@ -10,8 +10,12 @@ use std::time::Duration; #[tokio::test] #[ignore = "requires E2E environment - run with: cargo test --test e2e -- --ignored"] async fn test_root_folder_full_lifecycle() { - let mut ctx = TestContext::new().await.expect("Failed to create test context"); - setup_e2e_namespace(&ctx.client).await.expect("Failed to setup namespace"); + let mut ctx = TestContext::new() + .await + .expect("Failed to create test context"); + setup_e2e_namespace(&ctx.client) + .await + .expect("Failed to setup namespace"); let rf_name = unique_name("e2e-rf"); // Use a path that might exist in the container @@ -20,7 +24,11 @@ async fn test_root_folder_full_lifecycle() { ctx.register_cleanup("SonarrRootFolder", E2E_NAMESPACE, &rf_name); // Create the root folder CR - tracing::info!("Creating SonarrRootFolder: {} with path: {}", rf_name, rf_path); + tracing::info!( + "Creating SonarrRootFolder: {} with path: {}", + rf_name, + rf_path + ); let root_folder = SonarrRootFolder { metadata: ObjectMeta { name: Some(rf_name.clone()), @@ -37,18 +45,16 @@ async fn test_root_folder_full_lifecycle() { status: None, }; - apply_resource(&ctx.client, &root_folder).await.expect("Failed to create root folder CR"); + apply_resource(&ctx.client, &root_folder) + .await + .expect("Failed to create root folder CR"); // Wait for Ready condition tracing::info!("Waiting for root folder to be ready..."); - let ready_rf = wait_for_ready::( - &ctx.client, - E2E_NAMESPACE, - &rf_name, - E2E_TIMEOUT, - ) - .await - .expect("Root folder never became ready"); + let ready_rf = + wait_for_ready::(&ctx.client, E2E_NAMESPACE, &rf_name, E2E_TIMEOUT) + .await + .expect("Root folder never became ready"); let rf_id = ready_rf .status @@ -59,7 +65,8 @@ async fn test_root_folder_full_lifecycle() { // Verify in Sonarr API tracing::info!("Verifying root folder exists in Sonarr..."); - let sonarr_rf = ctx.sonarr + let sonarr_rf = ctx + .sonarr .find_root_folder_by_path(&rf_path) .await .expect("Failed to query Sonarr API") diff --git a/tests/integration/download_client_config_crd.rs b/tests/integration/download_client_config_crd.rs index c7d4316..8e11d37 100644 --- a/tests/integration/download_client_config_crd.rs +++ b/tests/integration/download_client_config_crd.rs @@ -49,7 +49,9 @@ async fn test_create_sonarr_download_client_config() { let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); let patch_params = PatchParams::apply("sonarr-operator-test").force(); - let result = api.patch(&name, &patch_params, &Patch::Apply(&config)).await; + let result = api + .patch(&name, &patch_params, &Patch::Apply(&config)) + .await; assert!( result.is_ok(), @@ -134,7 +136,10 @@ async fn test_update_sonarr_download_client_config() { .get(&name) .await .expect("Failed to get SonarrDownloadClientConfig"); - assert_eq!(retrieved.spec.enable_completed_download_handling, Some(true)); + assert_eq!( + retrieved.spec.enable_completed_download_handling, + Some(true) + ); assert_eq!(retrieved.spec.auto_redownload_failed, Some(true)); // Cleanup diff --git a/tests/integration/indexer_config_crd.rs b/tests/integration/indexer_config_crd.rs index 65a9e78..34f6732 100644 --- a/tests/integration/indexer_config_crd.rs +++ b/tests/integration/indexer_config_crd.rs @@ -49,7 +49,9 @@ async fn test_create_sonarr_indexer_config() { let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); let patch_params = PatchParams::apply("sonarr-operator-test").force(); - let result = api.patch(&name, &patch_params, &Patch::Apply(&config)).await; + let result = api + .patch(&name, &patch_params, &Patch::Apply(&config)) + .await; assert!( result.is_ok(), diff --git a/tests/integration/media_management_config_crd.rs b/tests/integration/media_management_config_crd.rs index 3ff3cad..c331cc5 100644 --- a/tests/integration/media_management_config_crd.rs +++ b/tests/integration/media_management_config_crd.rs @@ -51,7 +51,9 @@ async fn test_create_sonarr_media_management_config() { let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); let patch_params = PatchParams::apply("sonarr-operator-test").force(); - let result = api.patch(&name, &patch_params, &Patch::Apply(&config)).await; + let result = api + .patch(&name, &patch_params, &Patch::Apply(&config)) + .await; assert!( result.is_ok(), @@ -250,7 +252,9 @@ async fn test_singleton_constraint() { // This should succeed at the K8s level (resource is created) // but the controller should mark it as conflict - let result = api.patch(&name2, &patch_params, &Patch::Apply(&config2)).await; + let result = api + .patch(&name2, &patch_params, &Patch::Apply(&config2)) + .await; assert!( result.is_ok(), "Failed to create second SonarrMediaManagementConfig: {:?}", diff --git a/tests/integration/naming_config_crd.rs b/tests/integration/naming_config_crd.rs index 21726c8..ca5f964 100644 --- a/tests/integration/naming_config_crd.rs +++ b/tests/integration/naming_config_crd.rs @@ -52,7 +52,9 @@ async fn test_create_sonarr_naming_config() { let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); let patch_params = PatchParams::apply("sonarr-operator-test").force(); - let result = api.patch(&name, &patch_params, &Patch::Apply(&config)).await; + let result = api + .patch(&name, &patch_params, &Patch::Apply(&config)) + .await; assert!( result.is_ok(), From 6da98441645a067f651e99ce6b7234a43f45159a Mon Sep 17 00:00:00 2001 From: Fuochi Date: Sat, 28 Feb 2026 10:52:38 +0100 Subject: [PATCH 08/22] ci: install k3d for E2E tests, optimize pipeline parallelism and caching --- .github/workflows/ci.yml | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 741146c..8c1dc21 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,7 +53,7 @@ jobs: generate-crds: name: Generate CRDs runs-on: ubuntu-latest - needs: [lint, test] + needs: [lint] steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable @@ -101,7 +101,7 @@ jobs: build-image: name: Build & Push Image runs-on: ubuntu-latest - needs: [lint, test] + needs: [lint] permissions: contents: read packages: write @@ -159,6 +159,8 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 + with: + shared-key: integration-test - name: Download CRDs artifact uses: actions/download-artifact@v4 @@ -230,6 +232,9 @@ jobs: - name: Pull operator image run: docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.build-image.outputs.short-sha }} + - name: Install k3d + run: curl -s https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | bash + - name: Create k3d cluster with port mapping run: | k3d cluster create sonarr-e2e \ @@ -285,14 +290,14 @@ jobs: - name: Wait for Sonarr API to be available run: | echo "Waiting for Sonarr API on localhost:8989..." - for i in $(seq 1 60); do + for i in $(seq 1 90); do if curl -sf -H "X-Api-Key: ${API_KEY}" \ http://localhost:8989/api/v3/system/status > /dev/null 2>&1; then echo "Sonarr API is ready" exit 0 fi - echo "Attempt $i/60 - waiting..." - sleep 5 + echo "Attempt $i/90 - waiting..." + sleep 2 done echo "Sonarr API not reachable" kubectl get pods -A From 1532fcd4d49d4f1d49994515a0ef812e9800de70 Mon Sep 17 00:00:00 2001 From: Fuochi Date: Sat, 28 Feb 2026 10:58:04 +0100 Subject: [PATCH 09/22] ci: remove --no-lb from E2E k3d cluster (needed for port mapping) --- .github/workflows/ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c1dc21..606cecf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -239,7 +239,6 @@ jobs: run: | k3d cluster create sonarr-e2e \ --port 8989:30989@server:0 \ - --no-lb \ --k3s-arg '--disable=traefik,servicelb,metrics-server@server:*' - name: Import operator image into k3d From f4ba4e9fde03d3a65d9d1ed717ecaaa891ad9395 Mon Sep 17 00:00:00 2001 From: Fuochi Date: Sat, 28 Feb 2026 11:06:56 +0100 Subject: [PATCH 10/22] ci: set imagePullPolicy to IfNotPresent in deployment manifest --- deploy/deployment.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/deployment.yaml b/deploy/deployment.yaml index 8951ca1..08d6535 100644 --- a/deploy/deployment.yaml +++ b/deploy/deployment.yaml @@ -20,7 +20,7 @@ spec: containers: - name: operator image: ghcr.io/OWNER/sonarr-operator:latest - imagePullPolicy: Always + imagePullPolicy: IfNotPresent env: - name: RUST_LOG value: 'info,sonarr_operator=debug' From 3ab48bf7bdbce97539fc7eb069e3c08daef90cf1 Mon Sep 17 00:00:00 2001 From: Fuochi Date: Sat, 28 Feb 2026 11:18:19 +0100 Subject: [PATCH 11/22] ci: increase Sonarr CR wait timeout to 360s to avoid race with operator requeue --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 606cecf..6074f4e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -283,7 +283,7 @@ jobs: kubectl apply -f tests/e2e/fixtures/sonarr-instance.yaml echo "Waiting for Sonarr CR to be ready..." kubectl wait --for=jsonpath='{.status.conditions[?(@.type=="Ready")].status}'=True \ - sonarr/sonarr -n default --timeout=300s + sonarr/sonarr -n default --timeout=360s echo "Sonarr instance is ready" - name: Wait for Sonarr API to be available From a53ea88ed1dca3151bf6e3ffdb85a1cc6d283f5a Mon Sep 17 00:00:00 2001 From: Fuochi Date: Sat, 28 Feb 2026 16:46:59 +0100 Subject: [PATCH 12/22] fix: controller field mapping, quality profile population, E2E test data Controller fixes: - custom_format: map CRD fields (value/min/max) to Sonarr API Field objects - auto_tag: same field mapping fix as custom_format - quality_profile: follow Terraform provider approach, fetch all quality definitions and custom formats, include all in items/format_items - quality_profile: default min_upgrade_format_score to 1 (Sonarr v4 requires >= 1) - traits: write Ready=False status condition on reconcile errors - sonarr: reduce requeue to 15s when instance not ready E2E test fixes: - custom_format_lifecycle: add valid ReleaseTitleSpecification - cross_resource_dependencies: add specifications to AutoTag and CustomFormat - quality_profile_lifecycle: set min_upgrade_format_score to 1 - root_folder_lifecycle: use /tmp path that exists in Sonarr container - sonarr_client: fix autotag API endpoint from /autotag to /autotagging --- src/controllers/auto_tag.rs | 28 +++- src/controllers/custom_format.rs | 28 +++- src/controllers/quality_profile.rs | 144 +++++++++++++++++- src/controllers/sonarr.rs | 12 +- src/controllers/traits.rs | 43 +++++- .../scenarios/cross_resource_dependencies.rs | 40 ++++- .../e2e/scenarios/custom_format_lifecycle.rs | 15 +- .../scenarios/quality_profile_lifecycle.rs | 2 +- tests/e2e/scenarios/root_folder_lifecycle.rs | 4 +- tests/e2e/sonarr_client.rs | 4 +- 10 files changed, 296 insertions(+), 24 deletions(-) diff --git a/src/controllers/auto_tag.rs b/src/controllers/auto_tag.rs index 59a36e9..d45ffb9 100644 --- a/src/controllers/auto_tag.rs +++ b/src/controllers/auto_tag.rs @@ -6,7 +6,7 @@ use kube::{Client, ResourceExt}; use tracing::info; use sonarr::apis::auto_tagging_api; -use sonarr::models::{AutoTaggingResource, AutoTaggingSpecificationSchema}; +use sonarr::models::{AutoTaggingResource, AutoTaggingSpecificationSchema, Field}; use crate::Context; use crate::crds::auto_tag::{AutoTagImplementation, AutoTagSpecification}; @@ -113,7 +113,31 @@ fn convert_specification(spec: &AutoTagSpecification) -> AutoTaggingSpecificatio )); schema.negate = Some(spec.negate); schema.required = Some(spec.required); - // Fields would need more complex mapping + + // Map fields to Sonarr API Field objects + let mut fields = Vec::new(); + if let Some(ref value) = spec.fields.value { + let mut field = Field::new(); + field.name = Some(Some("value".to_string())); + field.value = Some(Some(serde_json::Value::String(value.clone()))); + fields.push(field); + } + if let Some(min) = spec.fields.min { + let mut field = Field::new(); + field.name = Some(Some("min".to_string())); + field.value = Some(Some(serde_json::json!(min))); + fields.push(field); + } + if let Some(max) = spec.fields.max { + let mut field = Field::new(); + field.name = Some(Some("max".to_string())); + field.value = Some(Some(serde_json::json!(max))); + fields.push(field); + } + if !fields.is_empty() { + schema.fields = Some(Some(fields)); + } + schema } diff --git a/src/controllers/custom_format.rs b/src/controllers/custom_format.rs index 4f51eec..ef841d9 100644 --- a/src/controllers/custom_format.rs +++ b/src/controllers/custom_format.rs @@ -6,7 +6,7 @@ use kube::{Client, ResourceExt}; use tracing::info; use sonarr::apis::custom_format_api; -use sonarr::models::{CustomFormatResource, CustomFormatSpecificationSchema}; +use sonarr::models::{CustomFormatResource, CustomFormatSpecificationSchema, Field}; use crate::Context; use crate::crds::custom_format::{CustomFormatImplementation, CustomFormatSpecification}; @@ -113,7 +113,31 @@ fn convert_specification(spec: &CustomFormatSpecification) -> CustomFormatSpecif )); schema.negate = Some(spec.negate); schema.required = Some(spec.required); - // Fields would need more complex mapping + + // Map fields to Sonarr API Field objects + let mut fields = Vec::new(); + if let Some(ref value) = spec.fields.value { + let mut field = Field::new(); + field.name = Some(Some("value".to_string())); + field.value = Some(Some(serde_json::Value::String(value.clone()))); + fields.push(field); + } + if let Some(min) = spec.fields.min { + let mut field = Field::new(); + field.name = Some(Some("min".to_string())); + field.value = Some(Some(serde_json::json!(min))); + fields.push(field); + } + if let Some(max) = spec.fields.max { + let mut field = Field::new(); + field.name = Some(Some("max".to_string())); + field.value = Some(Some(serde_json::json!(max))); + fields.push(field); + } + if !fields.is_empty() { + schema.fields = Some(Some(fields)); + } + schema } diff --git a/src/controllers/quality_profile.rs b/src/controllers/quality_profile.rs index ae1f01e..c532a23 100644 --- a/src/controllers/quality_profile.rs +++ b/src/controllers/quality_profile.rs @@ -5,8 +5,11 @@ use kube::runtime::controller::Action; use kube::{Client, ResourceExt}; use tracing::info; -use sonarr::apis::quality_profile_api; -use sonarr::models::QualityProfileResource; +use sonarr::apis::{custom_format_api, quality_definition_api, quality_profile_api}; +use sonarr::models::{ + ProfileFormatItemResource, Quality as SonarrQuality, QualityProfileQualityItemResource, + QualityProfileResource, +}; use crate::Context; use crate::crds::{SonarrQualityProfile, SonarrQualityProfileStatus}; @@ -43,6 +46,11 @@ async fn reconcile_apply(profile: Arc, ctx: Arc) let config = get_sonarr_config(&ctx, client, &namespace, &profile.spec.sonarr_instance_ref).await?; + // Fetch all quality definitions and custom formats from Sonarr + // (following the Terraform provider approach: include ALL qualities and formats) + let all_quality_defs = quality_definition_api::list_quality_definition(&config).await?; + let all_custom_formats = custom_format_api::list_custom_format(&config).await?; + // Build quality profile resource let mut qp_resource = QualityProfileResource::new(); qp_resource.name = Some(Some(profile.spec.name.clone())); @@ -50,7 +58,19 @@ async fn reconcile_apply(profile: Arc, ctx: Arc) qp_resource.cutoff = Some(profile.spec.cutoff); qp_resource.cutoff_format_score = profile.spec.cutoff_format_score; qp_resource.min_format_score = profile.spec.min_format_score; - // Note: Quality groups would need more complex mapping + qp_resource.min_upgrade_format_score = Some(profile.spec.min_upgrade_format_score.unwrap_or(1)); + + // Build full items list: allowed qualities from spec + not-allowed for the rest + qp_resource.items = Some(Some(build_quality_items( + &profile.spec.quality_groups, + &all_quality_defs, + ))); + + // Build full format items list: scored items from spec + score=0 for the rest + qp_resource.format_items = Some(Some(build_format_items( + &profile.spec.format_items, + &all_custom_formats, + ))); let sonarr_profile = if let Some(id) = profile.status.as_ref().and_then(|s| s.id) { qp_resource.id = Some(id); @@ -105,6 +125,124 @@ async fn reconcile_apply(profile: Arc, ctx: Arc) Ok(Action::requeue(REQUEUE_DURATION)) } +/// Build the full quality items list following the Terraform provider approach: +/// 1. Convert user-specified quality groups to allowed items +/// 2. Fill remaining quality definitions as not-allowed +/// 3. Reverse the list (higher quality to lower) +fn build_quality_items( + groups: &[crate::crds::quality_profile::QualityGroup], + all_quality_defs: &[sonarr::models::QualityDefinitionResource], +) -> Vec { + let mut allowed_quality_ids: Vec = Vec::new(); + let mut items: Vec = Vec::new(); + + // Convert user-specified quality groups to allowed items + for group in groups { + if group.qualities.len() == 1 { + // Single quality — set quality field directly (like Terraform provider) + let q = &group.qualities[0]; + let mut quality = SonarrQuality::new(); + quality.id = q.id; + quality.name = q.name.clone().map(Some); + + let mut item = QualityProfileQualityItemResource::new(); + item.allowed = Some(true); + item.quality = Some(Box::new(quality)); + // Single quality items don't set `items` (Terraform provider doesn't call SetItems) + + if let Some(id) = q.id { + allowed_quality_ids.push(id); + } + items.push(item); + } else { + // Quality group with multiple nested qualities + let sub_items: Vec = group + .qualities + .iter() + .map(|q| { + let mut quality = SonarrQuality::new(); + quality.id = q.id; + quality.name = q.name.clone().map(Some); + + if let Some(id) = q.id { + allowed_quality_ids.push(id); + } + + let mut sub_item = QualityProfileQualityItemResource::new(); + sub_item.allowed = Some(true); + sub_item.quality = Some(Box::new(quality)); + sub_item + }) + .collect(); + + let mut item = QualityProfileQualityItemResource::new(); + item.id = group.id; + item.name = group.name.clone().map(Some); + item.allowed = Some(true); + item.items = Some(Some(sub_items)); + items.push(item); + } + } + + // Fill remaining quality definitions as not-allowed + for qd in all_quality_defs { + let quality_id = qd.quality.as_ref().and_then(|q| q.id); + if let Some(id) = quality_id + && !allowed_quality_ids.contains(&id) + { + let mut quality = SonarrQuality::new(); + quality.id = Some(id); + + let mut item = QualityProfileQualityItemResource::new(); + item.allowed = Some(false); + item.items = Some(Some(vec![])); + item.quality = Some(Box::new(quality)); + items.push(item); + } + } + + // Reverse: higher quality to lower (matches Terraform provider behavior) + items.reverse(); + items +} + +/// Build the full format items list following the Terraform provider approach: +/// 1. Include user-specified format items with their scores +/// 2. Fill remaining custom formats with score=0 +fn build_format_items( + spec_items: &[crate::crds::quality_profile::FormatItem], + all_custom_formats: &[sonarr::models::CustomFormatResource], +) -> Vec { + let mut used_format_ids: Vec = Vec::new(); + let mut items: Vec = Vec::new(); + + // Convert user-specified format items + for fi in spec_items { + let mut item = ProfileFormatItemResource::new(); + item.format = fi.format; + item.name = fi.name.clone().map(Some); + item.score = Some(fi.score); + if let Some(id) = fi.format { + used_format_ids.push(id); + } + items.push(item); + } + + // Fill remaining custom formats with score=0 + for cf in all_custom_formats { + if let Some(id) = cf.id + && !used_format_ids.contains(&id) + { + let mut item = ProfileFormatItemResource::new(); + item.format = Some(id); + item.score = Some(0); + items.push(item); + } + } + + items +} + async fn reconcile_cleanup( profile: Arc, ctx: Arc, diff --git a/src/controllers/sonarr.rs b/src/controllers/sonarr.rs index 1d6c9a6..0c8b740 100644 --- a/src/controllers/sonarr.rs +++ b/src/controllers/sonarr.rs @@ -133,10 +133,12 @@ async fn reconcile_apply(instance: Arc, ctx: Arc) -> Result Result<()> { +async fn update_status(client: &Client, namespace: &str, instance: &Sonarr) -> Result { let instances: Api = Api::namespaced(client.clone(), namespace); let name = instance.name_any(); @@ -1125,5 +1127,5 @@ async fn update_status(client: &Client, namespace: &str, instance: &Sonarr) -> R .await .map_err(Error::KubeError)?; - Ok(()) + Ok(ready) } diff --git a/src/controllers/traits.rs b/src/controllers/traits.rs index b6d5fd4..2037762 100644 --- a/src/controllers/traits.rs +++ b/src/controllers/traits.rs @@ -22,7 +22,7 @@ use kube::runtime::watcher; use kube::{Client, Resource, ResourceExt}; use serde::{Serialize, de::DeserializeOwned}; use serde_json::json; -use tracing::{debug, error, info}; +use tracing::{debug, error, info, warn}; use crate::Context; use crate::crds::{FINALIZER, SonarrInstanceRef}; @@ -58,7 +58,8 @@ where /// Wrapper that handles the common finalizer pattern for sub-resources /// This eliminates boilerplate from individual controllers by wrapping -/// the kube-rs finalizer function with our standard error handling +/// the kube-rs finalizer function with our standard error handling. +/// On apply errors, it writes a failure status condition before propagating. pub async fn reconcile_with_finalizer( obj: Arc, ctx: Arc, @@ -85,10 +86,36 @@ where .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; let api: Api = Api::namespaced(client.clone(), &namespace); + let failure_client = client.clone(); + let failure_namespace = namespace.clone(); finalizer(&api, FINALIZER, obj.clone(), |event| async { match event { - Event::Apply(resource) => apply_fn(resource, ctx.clone()).await, + Event::Apply(resource) => { + let resource_name = resource.name_any(); + match apply_fn(resource.clone(), ctx.clone()).await { + Ok(action) => Ok(action), + Err(e) => { + // Write failure status so tests/users can see the error + let existing_conditions = extract_conditions(&*resource); + if let Err(status_err) = update_status_failure::( + &failure_client, + &failure_namespace, + &resource_name, + &format!("{}", e), + existing_conditions, + ) + .await + { + warn!( + "Failed to write error status for {}: {:?}", + resource_name, status_err + ); + } + Err(e) + } + } + } Event::Cleanup(resource) => cleanup_fn(resource, ctx.clone()).await, } }) @@ -96,6 +123,16 @@ where .map_err(|e| Error::FinalizerError(Box::new(e))) } +/// Extract existing conditions from a resource via serde. +/// This allows generic extraction without requiring a trait on each CRD type. +fn extract_conditions(obj: &R) -> Vec { + serde_json::to_value(obj) + .ok() + .and_then(|v| v.get("status")?.get("conditions")?.clone().into()) + .and_then(|c| serde_json::from_value::>(c).ok()) + .unwrap_or_default() +} + /// Start a generic controller for a Sonarr sub-resource pub async fn run_controller( client: Client, diff --git a/tests/e2e/scenarios/cross_resource_dependencies.rs b/tests/e2e/scenarios/cross_resource_dependencies.rs index d6067c8..423406d 100644 --- a/tests/e2e/scenarios/cross_resource_dependencies.rs +++ b/tests/e2e/scenarios/cross_resource_dependencies.rs @@ -9,6 +9,10 @@ use crate::common::*; use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; use kube::api::{Api, DeleteParams}; +use sonarr_operator::crds::auto_tag::{AutoTagFields, AutoTagImplementation, AutoTagSpecification}; +use sonarr_operator::crds::custom_format::{ + CustomFormatFields, CustomFormatImplementation, CustomFormatSpecification, +}; use sonarr_operator::crds::delay_profile::DownloadProtocol; use sonarr_operator::crds::{ SonarrAutoTag, SonarrAutoTagSpec, SonarrCustomFormat, SonarrCustomFormatSpec, @@ -94,7 +98,17 @@ async fn test_autotag_with_tag_dependency() { name: format!("E2E AutoTag {}", autotag_name), remove_tags_automatically: false, tags: vec![tag_id], // Reference the tag we created - specifications: vec![], + specifications: vec![AutoTagSpecification { + name: "Root Folder".to_string(), + implementation: AutoTagImplementation::RootFolderSpecification, + negate: false, + required: true, + fields: AutoTagFields { + value: Some("/config".to_string()), + min: None, + max: None, + }, + }], sonarr_instance_ref: SonarrInstanceRef { name: "sonarr".to_string(), namespace: Some("default".to_string()), @@ -304,7 +318,17 @@ async fn test_invalid_tag_reference_handling() { name: format!("E2E Invalid Ref {}", autotag_name), remove_tags_automatically: false, tags: vec![99999], // Non-existent tag ID - specifications: vec![], + specifications: vec![AutoTagSpecification { + name: "Root Folder".to_string(), + implementation: AutoTagImplementation::RootFolderSpecification, + negate: false, + required: true, + fields: AutoTagFields { + value: Some("/config".to_string()), + min: None, + max: None, + }, + }], sonarr_instance_ref: SonarrInstanceRef { name: "sonarr".to_string(), namespace: Some("default".to_string()), @@ -410,7 +434,17 @@ async fn test_full_dependency_chain() { spec: SonarrCustomFormatSpec { name: cf_name.clone(), include_custom_format_when_renaming: false, - specifications: vec![], + specifications: vec![CustomFormatSpecification { + name: "Test Regex".to_string(), + implementation: CustomFormatImplementation::ReleaseTitleSpecification, + negate: false, + required: true, + fields: CustomFormatFields { + value: Some("e2e-chain-test".to_string()), + min: None, + max: None, + }, + }], sonarr_instance_ref: SonarrInstanceRef { name: "sonarr".to_string(), namespace: Some("default".to_string()), diff --git a/tests/e2e/scenarios/custom_format_lifecycle.rs b/tests/e2e/scenarios/custom_format_lifecycle.rs index fdf3776..2c1af0d 100644 --- a/tests/e2e/scenarios/custom_format_lifecycle.rs +++ b/tests/e2e/scenarios/custom_format_lifecycle.rs @@ -3,6 +3,9 @@ use crate::common::*; use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; use kube::api::{Api, DeleteParams}; +use sonarr_operator::crds::custom_format::{ + CustomFormatFields, CustomFormatImplementation, CustomFormatSpecification, +}; use sonarr_operator::crds::{SonarrCustomFormat, SonarrCustomFormatSpec, SonarrInstanceRef}; use std::time::Duration; @@ -33,7 +36,17 @@ async fn test_custom_format_full_lifecycle() { spec: SonarrCustomFormatSpec { name: format_name.clone(), include_custom_format_when_renaming: false, - specifications: vec![], // Basic format without specifications + specifications: vec![CustomFormatSpecification { + name: "Test Regex".to_string(), + implementation: CustomFormatImplementation::ReleaseTitleSpecification, + negate: false, + required: true, + fields: CustomFormatFields { + value: Some("e2e-test-pattern".to_string()), + min: None, + max: None, + }, + }], sonarr_instance_ref: SonarrInstanceRef { name: "sonarr".to_string(), namespace: Some("default".to_string()), diff --git a/tests/e2e/scenarios/quality_profile_lifecycle.rs b/tests/e2e/scenarios/quality_profile_lifecycle.rs index 7ee1111..c4b2879 100644 --- a/tests/e2e/scenarios/quality_profile_lifecycle.rs +++ b/tests/e2e/scenarios/quality_profile_lifecycle.rs @@ -51,7 +51,7 @@ async fn test_quality_profile_full_lifecycle() { }, cutoff_format_score: None, min_format_score: None, - min_upgrade_format_score: None, + min_upgrade_format_score: Some(1), format_items: vec![], }, status: None, diff --git a/tests/e2e/scenarios/root_folder_lifecycle.rs b/tests/e2e/scenarios/root_folder_lifecycle.rs index 205c79a..fbd285d 100644 --- a/tests/e2e/scenarios/root_folder_lifecycle.rs +++ b/tests/e2e/scenarios/root_folder_lifecycle.rs @@ -18,8 +18,8 @@ async fn test_root_folder_full_lifecycle() { .expect("Failed to setup namespace"); let rf_name = unique_name("e2e-rf"); - // Use a path that might exist in the container - let rf_path = format!("/data/e2e-test-{}", rf_name); + // Use /tmp which exists in the Sonarr container (Sonarr validates the path exists) + let rf_path = "/tmp".to_string(); ctx.register_cleanup("SonarrRootFolder", E2E_NAMESPACE, &rf_name); diff --git a/tests/e2e/sonarr_client.rs b/tests/e2e/sonarr_client.rs index cceb9ee..583fbe1 100644 --- a/tests/e2e/sonarr_client.rs +++ b/tests/e2e/sonarr_client.rs @@ -263,7 +263,7 @@ impl SonarrTestClient { pub async fn get_auto_tags(&self) -> Result> { let resp = self .client - .get(format!("{}/api/v3/autotag", self.base_url)) + .get(format!("{}/api/v3/autotagging", self.base_url)) .send() .await? .error_for_status()?; @@ -279,7 +279,7 @@ impl SonarrTestClient { /// Delete an auto tag by ID pub async fn delete_auto_tag(&self, id: i32) -> Result<()> { self.client - .delete(format!("{}/api/v3/autotag/{}", self.base_url, id)) + .delete(format!("{}/api/v3/autotagging/{}", self.base_url, id)) .send() .await? .error_for_status()?; From ae54680e77e088eee783c92f6488cf45a29e3887 Mon Sep 17 00:00:00 2001 From: Fuochi Date: Sat, 28 Feb 2026 17:43:58 +0100 Subject: [PATCH 13/22] feat: add CI/CD workflow for release management and multi-arch image build --- .github/workflows/cd.yml | 79 ++++++++++++++++++++++++++++++++++++++ deploy/deployment.yaml | 2 +- release-please-config.json | 12 ++++++ 3 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/cd.yml create mode 100644 release-please-config.json diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000..ff591c5 --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,79 @@ +name: Release + +on: + push: + branches: + - main + +permissions: + contents: write + pull-requests: write + packages: write + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + # ============================================ + # Release Please - Version Management + # ============================================ + release-please: + name: Release Please + runs-on: ubuntu-latest + outputs: + release_created: ${{ steps.release.outputs.release_created }} + tag_name: ${{ steps.release.outputs.tag_name }} + version: ${{ steps.release.outputs.version }} + steps: + - uses: googleapis/release-please-action@v4 + id: release + + # ============================================ + # Build and Push Multi-arch Image on Release + # ============================================ + build-and-push: + name: Build & Push Multi-arch Image + runs-on: ubuntu-latest + needs: release-please + if: ${{ needs.release-please.outputs.release_created }} + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=semver,pattern=v{{version}},value=${{ needs.release-please.outputs.version }} + type=semver,pattern=v{{major}}.{{minor}},value=${{ needs.release-please.outputs.version }} + type=semver,pattern=v{{major}},value=${{ needs.release-please.outputs.version }} + type=raw,value=latest + + - name: Build and push multi-arch Docker image + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/deploy/deployment.yaml b/deploy/deployment.yaml index 08d6535..fb72f9f 100644 --- a/deploy/deployment.yaml +++ b/deploy/deployment.yaml @@ -19,7 +19,7 @@ spec: serviceAccountName: sonarr-operator containers: - name: operator - image: ghcr.io/OWNER/sonarr-operator:latest + image: ghcr.io/devopsarr/k8s-operator-sonarr:v0.1.0 # x-release-please-version imagePullPolicy: IfNotPresent env: - name: RUST_LOG diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..aae3a3e --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "packages": { + ".": { + "release-type": "rust", + "initial-version": "0.1.0", + "extra-files": [ + "deploy/deployment.yaml" + ] + } + } +} From 546cb7497d24e586ae93d0e225ccb193abbdf5a3 Mon Sep 17 00:00:00 2001 From: Fuochi Date: Sat, 28 Feb 2026 17:51:25 +0100 Subject: [PATCH 14/22] refactor: streamline Makefile for improved testing and E2E workflows --- Makefile | 195 +++++++++++++++++++++---------------------------------- 1 file changed, 74 insertions(+), 121 deletions(-) diff --git a/Makefile b/Makefile index 3f9cd5a..7a91442 100644 --- a/Makefile +++ b/Makefile @@ -1,25 +1,21 @@ # Sonarr Kubernetes Operator Makefile # # Usage: -# make help - Show this help -# make check - Run cargo check -# make build - Build release binary -# make crds - Generate CRD manifests -# make install - Install CRDs to cluster -# make run - Run operator locally -# make docker - Build Docker image -# make test - Run tests -# make e2e - Run end-to-end tests +# make help - Show this help +# make build - Build release binary +# make crds - Generate CRD manifests +# make test - Run unit tests +# make e2e-up - Full local E2E environment setup +# make e2e - Run E2E tests +# make e2e-down - Tear down local E2E environment # Configuration BINARY_NAME := sonarr-operator -IMAGE_NAME := sonarr-operator -IMAGE_TAG := latest -NAMESPACE := sonarr-operator-system CRD_DIR := deploy/crds - -# Kubernetes context (optional, uses current context if not set) -# KUBECONFIG := ~/.kube/config +NAMESPACE := sonarr-operator-system +K3D_CLUSTER := sonarr-e2e +E2E_API_KEY ?= test-e2e-api-key-12345 +SONARR_URL ?= http://localhost:8989 .PHONY: help help: ## Show this help @@ -46,32 +42,6 @@ clippy: ## Run clippy linter .PHONY: lint lint: fmt-check clippy ## Run all linters -.PHONY: test -test: ## Run unit tests - cargo test - -.PHONY: test-verbose -test-verbose: ## Run tests with verbose output - cargo test -- --nocapture - -.PHONY: integration-test -integration-test: ## Run integration tests (requires cluster and CRDs installed) - cargo test --test integration -- --ignored --test-threads=1 - -.PHONY: integration-test-verbose -integration-test-verbose: ## Run integration tests with verbose output - cargo test --test integration -- --ignored --test-threads=1 --nocapture - -.PHONY: integration-setup -integration-setup: kind-create install ## Setup integration test environment - @echo "Integration test environment ready" - @echo "Run 'make integration-test' to execute tests" - -.PHONY: integration-cleanup -integration-cleanup: ## Cleanup integration test resources - kubectl delete namespace sonarr-operator-test --ignore-not-found - @echo "Integration test resources cleaned up" - ##@ Build .PHONY: build @@ -85,26 +55,40 @@ build-debug: ## Build debug binary .PHONY: clean clean: ## Clean build artifacts cargo clean - rm -rf $(CRD_DIR) + +##@ Testing + +.PHONY: test +test: ## Run unit tests + cargo test --lib + +.PHONY: test-all +test-all: ## Run all non-ignored tests + cargo test + +.PHONY: integration-test +integration-test: install ## Run integration tests (requires cluster with CRDs) + cargo test --test integration -- --ignored --test-threads=1 + +.PHONY: integration-test-verbose +integration-test-verbose: install ## Run integration tests with verbose output + cargo test --test integration -- --ignored --test-threads=1 --nocapture ##@ CRD Management .PHONY: crds -crds: ## Generate CRD manifests (individual files) +crds: ## Generate CRD manifests @mkdir -p $(CRD_DIR) @rm -f $(CRD_DIR)/*.yaml cargo run --bin crdgen -- --split $(CRD_DIR) -.PHONY: crds-combined -crds-combined: ## Generate all CRDs in a single file - @mkdir -p $(CRD_DIR) - cargo run --bin crdgen > $(CRD_DIR)/crds.yaml - @echo "CRDs generated in $(CRD_DIR)/crds.yaml" +.PHONY: install +install: crds ## Install CRDs to cluster + kubectl apply -f $(CRD_DIR)/ -.PHONY: crds-single -crds-single: ## Generate a single CRD (use: make crds-single CRD=SonarrTag) - @mkdir -p $(CRD_DIR) - cargo run --bin crdgen -- --single $(CRD) > $(CRD_DIR)/$(shell echo $(CRD) | tr '[:upper:]' '[:lower:]').yaml +.PHONY: uninstall +uninstall: ## Remove CRDs from cluster + kubectl delete -f $(CRD_DIR)/ --ignore-not-found ##@ Documentation @@ -114,7 +98,6 @@ GOBIN := $(shell go env GOPATH)/bin .PHONY: docs docs: crds ## Generate CRD documentation @mkdir -p $(DOCS_DIR) - @echo "Generating CRD documentation..." @if ! command -v crdoc >/dev/null 2>&1 && [ ! -f "$(GOBIN)/crdoc" ]; then \ echo "Installing crdoc..."; \ go install fybrik.io/crdoc@latest; \ @@ -122,20 +105,6 @@ docs: crds ## Generate CRD documentation @PATH="$(GOBIN):$$PATH" crdoc --resources $(CRD_DIR) --output $(DOCS_DIR)/crd-reference.md @echo "Documentation generated in $(DOCS_DIR)/crd-reference.md" -.PHONY: docs-html -docs-html: docs ## Generate HTML documentation (requires pandoc) - @command -v pandoc >/dev/null 2>&1 || { echo "ERROR: pandoc not installed"; exit 1; } - pandoc $(DOCS_DIR)/crd-reference.md -o $(DOCS_DIR)/crd-reference.html --standalone --toc --metadata title="Sonarr Operator CRD Reference" - @echo "HTML documentation generated in $(DOCS_DIR)/crd-reference.html" - -.PHONY: install -install: crds ## Install CRDs to cluster - kubectl apply -f $(CRD_DIR)/ - -.PHONY: uninstall -uninstall: ## Remove CRDs from cluster - kubectl delete -f $(CRD_DIR)/ --ignore-not-found - ##@ Running .PHONY: run @@ -149,20 +118,17 @@ run-debug: ## Run operator with debug logging ##@ Docker .PHONY: docker -docker: ## Build Docker image - docker build -t $(IMAGE_NAME):$(IMAGE_TAG) . - -.PHONY: docker-push -docker-push: docker ## Push Docker image to registry - docker push $(IMAGE_NAME):$(IMAGE_TAG) +docker: ## Build Docker image and import into k3d cluster + docker build -t $(BINARY_NAME):latest . + k3d image import $(BINARY_NAME):latest --cluster $(K3D_CLUSTER) ##@ Kubernetes Deployment .PHONY: deploy -deploy: install ## Deploy operator to cluster +deploy: install docker ## Deploy operator to cluster kubectl apply -f deploy/namespace.yaml kubectl apply -f deploy/rbac.yaml - kubectl apply -f deploy/deployment.yaml + sed 's|image: .*|image: $(BINARY_NAME):latest|' deploy/deployment.yaml | kubectl apply -f - .PHONY: undeploy undeploy: ## Remove operator from cluster @@ -170,74 +136,61 @@ undeploy: ## Remove operator from cluster kubectl delete -f deploy/rbac.yaml --ignore-not-found kubectl delete -f deploy/namespace.yaml --ignore-not-found -##@ Testing with Kind - -.PHONY: kind-create -kind-create: ## Create a Kind cluster for testing - kind create cluster --name sonarr-operator-test - -.PHONY: kind-delete -kind-delete: ## Delete the Kind cluster - kind delete cluster --name sonarr-operator-test - -.PHONY: kind-load -kind-load: docker ## Load Docker image into Kind cluster - kind load docker-image $(IMAGE_NAME):$(IMAGE_TAG) --name sonarr-operator-test - -##@ End-to-End Testing -# -# The E2E flow is: -# 1. Create a k3d/Kind cluster and install CRDs -# 2. Run the operator (locally or in-cluster) -# 3. Create an API key Secret + apply the Sonarr CR -# 4. The operator creates the Sonarr Deployment, Service, PVC -# 5. Run E2E tests against the live Sonarr instance +##@ Local E2E Testing (k3d) # -# Required env vars: SONARR_API_KEY, SONARR_URL - -E2E_API_KEY ?= test-e2e-api-key-12345 +# Full local E2E workflow: +# 1. make e2e-up - Create k3d cluster, install CRDs, deploy Sonarr +# 2. make run-debug - Run operator locally (in a separate terminal) +# 3. make e2e - Run E2E tests +# 4. make e2e-down - Tear down k3d cluster + +.PHONY: e2e-cluster-create +e2e-cluster-create: ## Create k3d cluster with port mapping (8989 -> 30989) + k3d cluster create $(K3D_CLUSTER) \ + --port 8989:30989@server:0 \ + --k3s-arg '--disable=traefik,servicelb,metrics-server@server:*' + +.PHONY: e2e-cluster-delete +e2e-cluster-delete: ## Delete k3d cluster + k3d cluster delete $(K3D_CLUSTER) .PHONY: e2e-deploy-sonarr -e2e-deploy-sonarr: install ## Create API key secret + apply Sonarr CR (operator must be running) - @echo "Creating API key secret..." +e2e-deploy-sonarr: install ## Create API key secret + apply Sonarr CR kubectl create secret generic sonarr-api-key \ --from-literal=api-key="$(E2E_API_KEY)" \ -n default --dry-run=client -o yaml | kubectl apply -f - - @echo "Applying Sonarr CR..." kubectl apply -f tests/e2e/fixtures/sonarr-instance.yaml @echo "Waiting for Sonarr to be ready..." kubectl wait --for=jsonpath='{.status.conditions[?(@.type=="Ready")].status}'=True \ sonarr/sonarr -n default --timeout=300s @echo "Sonarr instance is ready" +.PHONY: e2e-up +e2e-up: e2e-cluster-create e2e-deploy-sonarr ## Full E2E setup: create cluster + deploy Sonarr + @echo "" + @echo "E2E environment is ready." + @echo "Next steps:" + @echo " 1. Run the operator: make run-debug" + @echo " 2. Run E2E tests: make e2e" + +.PHONY: e2e-down +e2e-down: e2e-cluster-delete ## Tear down E2E environment + .PHONY: e2e e2e: ## Run E2E tests (requires operator + Sonarr running) - @echo "Running E2E tests..." - @if [ -z "$$SONARR_API_KEY" ]; then \ - echo "ERROR: SONARR_API_KEY environment variable not set"; \ - echo "Run: export SONARR_API_KEY=$(E2E_API_KEY)"; \ - exit 1; \ - fi - @if [ -z "$$SONARR_URL" ]; then \ - export SONARR_URL="http://localhost:8989"; \ - fi - cargo test --test e2e -- --ignored --test-threads=1 + SONARR_API_KEY=$(E2E_API_KEY) SONARR_URL=$(SONARR_URL) \ + cargo test --test e2e -- --ignored --test-threads=1 .PHONY: e2e-verbose e2e-verbose: ## Run E2E tests with verbose output - @echo "Running E2E tests (verbose)..." - @if [ -z "$$SONARR_API_KEY" ]; then \ - echo "ERROR: SONARR_API_KEY environment variable not set"; \ - exit 1; \ - fi - RUST_LOG=debug cargo test --test e2e -- --ignored --test-threads=1 --nocapture + SONARR_API_KEY=$(E2E_API_KEY) SONARR_URL=$(SONARR_URL) RUST_LOG=debug \ + cargo test --test e2e -- --ignored --test-threads=1 --nocapture .PHONY: e2e-cleanup -e2e-cleanup: ## Cleanup E2E test resources +e2e-cleanup: ## Cleanup E2E test resources (without deleting cluster) kubectl delete -f tests/e2e/fixtures/sonarr-instance.yaml --ignore-not-found kubectl delete secret sonarr-api-key -n default --ignore-not-found kubectl delete namespace sonarr-e2e-test --ignore-not-found - @echo "E2E resources cleaned up" ##@ Complete Workflows From a2241db3277b53fb137ff694038b030f35557b17 Mon Sep 17 00:00:00 2001 From: Fuochi Date: Sat, 28 Feb 2026 17:55:29 +0100 Subject: [PATCH 15/22] feat: add workflow to generate and publish CRDs and documentation on release --- .github/workflows/cd.yml | 53 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index ff591c5..6c8cf93 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -77,3 +77,56 @@ jobs: labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max + + # ============================================ + # Generate and Publish Docs & CRDs on Release + # ============================================ + publish-docs: + name: Publish Docs & CRDs + runs-on: ubuntu-latest + needs: release-please + if: ${{ needs.release-please.outputs.release_created }} + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + ref: main + + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + + - uses: actions/setup-go@v5 + with: + go-version: '1.22' + + - name: Generate CRDs + run: | + mkdir -p deploy/crds + rm -f deploy/crds/*.yaml + cargo run --bin crdgen -- --split deploy/crds + + - name: Generate documentation + run: | + go install fybrik.io/crdoc@latest + mkdir -p docs/api + crdoc --resources deploy/crds --output docs/api/crd-reference.md + + - name: Commit and push docs + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add deploy/crds/ docs/api/ + if git diff --cached --quiet; then + echo "No documentation changes to commit" + else + git commit -m "docs: update CRDs and documentation for ${{ needs.release-please.outputs.tag_name }}" + git push + fi + + - name: Attach CRDs to release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + tar czf crds.tar.gz -C deploy/crds . + gh release upload ${{ needs.release-please.outputs.tag_name }} crds.tar.gz From fff04f0fdaba3ccfbd7049e778407f8b985d324a Mon Sep 17 00:00:00 2001 From: Fuochi Date: Fri, 13 Mar 2026 21:53:50 +0100 Subject: [PATCH 16/22] chore: update inconsistent doc --- Cargo.toml | 6 +- README.md | 168 +++++++++++------------------------------------- docs/TESTING.md | 122 +++++++++-------------------------- 3 files changed, 71 insertions(+), 225 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 9c840d3..7b7b6fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,10 +2,10 @@ name = "sonarr-operator" version = "0.1.0" edition = "2024" -authors = ["Sonarr Operator Contributors"] +authors = ["DevOpsArr"] description = "Kubernetes Operator for Sonarr written in Rust using kube-rs" -license = "MIT" -repository = "https://github.com/your-org/kubernetes-operator-sonarr" +license = "GPL-3.0" +repository = "https://github.com/devopsarr/k8s-operator-sonarr" keywords = ["kubernetes", "operator", "sonarr", "kube-rs"] [lib] diff --git a/README.md b/README.md index 63c9f78..0e50f31 100644 --- a/README.md +++ b/README.md @@ -58,29 +58,24 @@ For detailed API specifications, see the [CRD Reference](docs/api/crd-reference. ### Prerequisites -- Kubernetes cluster (1.25+) -- Rust toolchain (for building from source) -- A running Sonarr instance +- Kubernetes cluster (1.28+) +- [k3d](https://k3d.io/) for local development +- Rust toolchain (1.88+) for building from source ### Installation 1. **Install CRDs** ```bash -make crds -kubectl apply -f deploy/crds/crds.yaml +kubectl apply -f deploy/crds/ ``` 2. **Deploy the Operator** ```bash -# Build and push Docker image (adjust image name as needed) -make docker -docker tag sonarr-operator:latest your-registry/sonarr-operator:latest -docker push your-registry/sonarr-operator:latest - -# Deploy to cluster -kubectl apply -f deploy/ +kubectl apply -f deploy/namespace.yaml +kubectl apply -f deploy/rbac.yaml +kubectl apply -f deploy/deployment.yaml ``` 3. **Create a Sonarr Instance Reference** @@ -128,136 +123,51 @@ spec: path: "/media/tv" ``` -## Usage Examples - -### Managing Tags - -```yaml -apiVersion: devopsarr.io/v1alpha1 -kind: SonarrTag -metadata: - name: documentary -spec: - sonarrInstanceRef: - name: my-sonarr - label: "documentary" -``` - -### Adding a Series - -```yaml -apiVersion: devopsarr.io/v1alpha1 -kind: SonarrSeries -metadata: - name: breaking-bad -spec: - sonarrInstanceRef: - name: my-sonarr - tvdbId: 81189 - qualityProfileId: 1 - rootFolderPath: "/media/tv" - monitored: true - seasonFolder: true - seriesType: Standard - monitor: All - searchOnAdd: true - tags: [] -``` - -### Configuring a Download Client - -```yaml -apiVersion: devopsarr.io/v1alpha1 -kind: SonarrDownloadClient -metadata: - name: qbittorrent -spec: - sonarrInstanceRef: - name: my-sonarr - name: "qBittorrent" - enable: true - priority: 1 - downloadClientType: QBittorrent - fields: - - name: host - value: "qbittorrent.example.com" - - name: port - value: "8080" - - name: username - value: "admin" - - name: password - secretRef: - name: qbittorrent-secret - key: password -``` +## Development -### Setting Up Notifications +### Building -```yaml -apiVersion: devopsarr.io/v1alpha1 -kind: SonarrNotification -metadata: - name: discord -spec: - sonarrInstanceRef: - name: my-sonarr - name: "Discord" - notificationType: Discord - triggers: - onGrab: true - onDownload: true - onUpgrade: true - onRename: false - onSeriesAdd: true - onSeriesDelete: false - onEpisodeFileDelete: false - onEpisodeFileDeleteForUpgrade: false - onHealthIssue: true - onHealthRestored: true - onApplicationUpdate: false - onManualInteractionRequired: false - includeHealthWarnings: false - tags: [] +```bash +make build # Release binary +make build-debug # Debug binary +make crds # Generate CRD manifests +make docs # Generate CRD documentation ``` -## Building from Source +### Linting & Testing ```bash -# Clone the repository -git clone https://github.com/your-org/kubernetes-operator-sonarr.git -cd kubernetes-operator-sonarr - -# Build -make build - -# Run locally (requires kubeconfig) -make run +make lint # Format check + clippy +make test # Unit tests +make integration-test # Integration tests (requires cluster with CRDs) ``` -## Development +### Local E2E Testing + +The local E2E workflow uses [k3d](https://k3d.io/) to create a cluster with Sonarr: ```bash -# Check code -make check +# Terminal 1: Create cluster + deploy Sonarr +make e2e-up -# Run linters -make lint +# Terminal 2: Run the operator locally +make run-debug -# Run tests -make test +# Terminal 3: Run E2E tests +make e2e -# Generate CRDs -make crds +# Cleanup +make e2e-down ``` -## Testing +See [docs/TESTING.md](docs/TESTING.md) for more details. -See [docs/TESTING.md](docs/TESTING.md) for comprehensive testing instructions, including: +### Deploy to a Local Cluster -- Unit testing -- Local development with Kind -- End-to-end testing with a real Sonarr instance -- CI/CD integration +```bash +make deploy # Build image, import into k3d, deploy operator +make undeploy # Remove operator from cluster +``` ## Architecture @@ -327,23 +237,19 @@ Each controller runs an independent reconciliation loop: |----------|-------------|---------| | `RUST_LOG` | Log level (trace, debug, info, warn, error) | `info` | -### Operator Flags - -The operator currently does not require any command-line flags and uses the default kubeconfig or in-cluster configuration. - ## Contributing Contributions are welcome! Please feel free to submit a Pull Request. 1. Fork the repository 2. Create your feature branch (`git checkout -b feature/amazing-feature`) -3. Commit your changes (`git commit -m 'Add some amazing feature'`) +3. Commit your changes using [Conventional Commits](https://www.conventionalcommits.org/) (`git commit -m 'feat: add amazing feature'`) 4. Push to the branch (`git push origin feature/amazing-feature`) 5. Open a Pull Request ## License -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. +This project is licensed under the GPL-3.0 License - see the [LICENSE](LICENSE) file for details. ## Acknowledgments diff --git a/docs/TESTING.md b/docs/TESTING.md index 6432dbc..a6baaf0 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -1,21 +1,21 @@ # Testing the Sonarr Kubernetes Operator -This guide covers different approaches to testing the operator, from unit tests to full end-to-end testing on Kubernetes. +This guide covers the different testing approaches, from unit tests to full end-to-end testing on Kubernetes. ## Prerequisites - Rust toolchain (1.88+) - Docker - kubectl -- [k3d](https://k3d.io/) or [Kind](https://kind.sigs.k8s.io/) for local Kubernetes clusters +- [k3d](https://k3d.io/) for local Kubernetes clusters ## Quick Start ```bash -# Run linting and unit tests +# Lint and unit tests make lint test -# Build the operator +# Build make build # Generate CRDs @@ -28,8 +28,6 @@ make crds ```bash make test -# or with verbose output -make test-verbose ``` Unit tests are located in each module and test individual functions without external dependencies. @@ -41,14 +39,15 @@ Unit tests are located in each module and test individual functions without exte Integration tests verify CRD schemas and validation against a live Kubernetes cluster (no Sonarr instance required). ```bash -# Setup -make kind-create install +# Create a k3d cluster and install CRDs +make e2e-cluster-create +make install -# Run -cargo test --test integration -- --ignored --test-threads=1 +# Run integration tests +make integration-test # Cleanup -make kind-delete +make e2e-cluster-delete ``` --- @@ -59,10 +58,8 @@ E2E tests verify the full reconciliation loop: the operator watches CRDs, calls ### Architecture -The E2E flow mirrors the CI pipeline: - -1. Create a Kubernetes cluster and install CRDs -2. Run the operator (locally or as a Deployment) +1. Create a k3d cluster with NodePort mapping and install CRDs +2. Run the operator locally (or as a Deployment) 3. Create an API key Secret and apply the **Sonarr CR** 4. The operator reconciles the Sonarr CR → creates Deployment, Service, PVC 5. Sonarr boots with the deterministic API key @@ -71,22 +68,17 @@ The E2E flow mirrors the CI pipeline: ### Local Setup ```bash -# Create a k3d cluster with NodePort mapping (host 8989 → node 30989) -k3d cluster create sonarr-e2e --port 8989:30989@server:0 --no-lb +# Terminal 1: Create cluster + deploy Sonarr +make e2e-up -# Install CRDs -make install - -# Run the operator (terminal 1) +# Terminal 2: Run the operator locally make run-debug -# Deploy Sonarr via its CRD (terminal 2) -export SONARR_API_KEY="my-test-api-key" -make e2e-deploy-sonarr - -# Run the E2E tests -export SONARR_URL="http://localhost:8989" +# Terminal 3: Run E2E tests make e2e + +# Verbose with debug logging +make e2e-verbose ``` ### Fixture Files @@ -97,74 +89,32 @@ The only fixture file is: All test resources (Tags, RootFolders, QualityProfiles, etc.) are created programmatically by the Rust test code. -### Running E2E Tests - -```bash -# Standard run -make e2e - -# Verbose with debug logging -make e2e-verbose -``` - ### Cleanup ```bash +# Remove test resources (keep cluster) make e2e-cleanup -k3d cluster delete sonarr-e2e + +# Tear down everything +make e2e-down ``` --- ## 4. CI Pipeline -The GitHub Actions CI pipeline automates the full E2E flow: +The GitHub Actions CI pipeline (`.github/workflows/ci.yml`) automates the full test suite: -1. **Build** the operator image and push to GHCR -2. **Create** a k3d cluster with NodePort mapping -3. **Deploy** the operator as a Kubernetes Deployment (from the GHCR image) -4. **Apply** the Sonarr CR with a deterministic API key -5. **Run** E2E tests against the live Sonarr instance - -See `.github/workflows/ci.yml` for the full pipeline definition. +1. **Lint & Format** — `cargo fmt --check` + `cargo clippy` +2. **Unit Tests** — `cargo test --lib` +3. **Generate CRDs** — `cargo run --bin crdgen` +4. **Build & Push Image** — Docker build + push to GHCR +5. **Integration Tests** — CRD schema validation on K8s v1.28, v1.29, and latest +6. **E2E Tests** — Full reconciliation against a live Sonarr instance in k3d --- -## 5. Testing Individual Components - -### Test CRD Generation - -```bash -make crds -ls deploy/crds/ - -# Generate a single CRD -make crds-single CRD=SonarrTag -``` - -### Test a Specific Resource - -```bash -kubectl apply -f - < Date: Fri, 13 Mar 2026 21:54:04 +0100 Subject: [PATCH 17/22] fix: clean up unused code --- .dockerignore | 12 +++++++++++- Dockerfile | 3 +-- src/controllers/mod.rs | 4 ++-- src/controllers/traits.rs | 3 --- src/controllers/utils.rs | 12 ------------ src/crds/mod.rs | 26 -------------------------- 6 files changed, 14 insertions(+), 46 deletions(-) diff --git a/.dockerignore b/.dockerignore index c35ff1e..92453af 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,7 +1,17 @@ /.github -/manifests /target +/tests +/docs +/deploy .vscode .cursorrules .idea Dockerfile +.dockerignore +.gitignore +Makefile +README.md +AI_PLAN.md +LICENSE +release-please-config.json +.release-please-manifest.json diff --git a/Dockerfile b/Dockerfile index 3f4427a..d8d914d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,8 +2,7 @@ FROM rust:1.88-slim-bookworm AS builder WORKDIR /app -# Install dependencies -RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* +# No system dependencies needed - project uses rustls # Copy manifests COPY Cargo.toml Cargo.lock ./ diff --git a/src/controllers/mod.rs b/src/controllers/mod.rs index 6257294..15f81ac 100644 --- a/src/controllers/mod.rs +++ b/src/controllers/mod.rs @@ -21,7 +21,7 @@ pub mod traits; mod utils; pub use traits::{ - ERROR_REQUEUE_DURATION, HasSonarrInstanceRef, REQUEUE_DURATION, get_sonarr_config, - run_controller, update_status_failure, update_status_success, + HasSonarrInstanceRef, REQUEUE_DURATION, get_sonarr_config, run_controller, + update_status_failure, update_status_success, }; pub use utils::*; diff --git a/src/controllers/traits.rs b/src/controllers/traits.rs index 2037762..fb2003a 100644 --- a/src/controllers/traits.rs +++ b/src/controllers/traits.rs @@ -255,6 +255,3 @@ where /// Common requeue duration for successful reconciliation pub const REQUEUE_DURATION: Duration = Duration::from_secs(300); - -/// Common requeue duration for errors -pub const ERROR_REQUEUE_DURATION: Duration = Duration::from_secs(60); diff --git a/src/controllers/utils.rs b/src/controllers/utils.rs index fe9e304..cc207ee 100644 --- a/src/controllers/utils.rs +++ b/src/controllers/utils.rs @@ -28,18 +28,6 @@ pub fn progressing_condition(status: bool, reason: &str, message: &str) -> Condi } } -/// Create a Synced condition -pub fn synced_condition(status: bool, reason: &str, message: &str) -> Condition { - Condition { - type_: "Synced".to_string(), - status: if status { "True" } else { "False" }.to_string(), - reason: reason.to_string(), - message: message.to_string(), - last_transition_time: now(), - observed_generation: None, - } -} - /// Update conditions list, replacing existing conditions of the same type pub fn update_conditions(conditions: &mut Vec, new_condition: Condition) { if let Some(existing) = conditions diff --git a/src/crds/mod.rs b/src/crds/mod.rs index 3661abc..ad41823 100644 --- a/src/crds/mod.rs +++ b/src/crds/mod.rs @@ -1,4 +1,3 @@ -use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -80,35 +79,10 @@ pub struct SonarrInstanceRef { pub namespace: Option, } -/// Base status fields shared by all Sonarr sub-resources (tags, notifications, etc.) -/// This provides a consistent status structure with conditions, id, and observed generation. -#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct SonarrSubResourceStatus { - /// Current conditions - #[serde(default)] - pub conditions: Vec, - - /// Sonarr resource ID - #[serde(default)] - pub id: Option, - - /// Observed generation - #[serde(default)] - pub observed_generation: i64, -} - /// Common constants for the operator pub const FINALIZER: &str = "sonarr.io/finalizer"; -pub const SONARR_GROUP: &str = "devopsarr.io"; -pub const SONARR_VERSION: &str = "v1alpha1"; /// Common labels pub const LABEL_APP: &str = "app.kubernetes.io/name"; pub const LABEL_INSTANCE: &str = "app.kubernetes.io/instance"; pub const LABEL_MANAGED_BY: &str = "app.kubernetes.io/managed-by"; -pub const LABEL_COMPONENT: &str = "app.kubernetes.io/component"; - -/// Annotations -pub const ANNOTATION_SONARR_REF: &str = "sonarr.io/instance"; -pub const ANNOTATION_RESOURCE_ID: &str = "sonarr.io/resource-id"; From 210d2276508307605c5c5b577d8a839f49bcd69f Mon Sep 17 00:00:00 2001 From: Fuochi Date: Fri, 13 Mar 2026 21:55:20 +0100 Subject: [PATCH 18/22] fix: missing release please manifest --- .release-please-manifest.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 .release-please-manifest.json diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1 @@ +{} From 29da9f94d0247f0f869c62825524f80e3b7569a9 Mon Sep 17 00:00:00 2001 From: Fuochi Date: Fri, 13 Mar 2026 22:04:07 +0100 Subject: [PATCH 19/22] feat: integrate DevOpsArrBOT for token management and update git config in CI workflows --- .github/workflows/cd.yml | 36 ++++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 6c8cf93..79423b3 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -26,8 +26,28 @@ jobs: tag_name: ${{ steps.release.outputs.tag_name }} version: ${{ steps.release.outputs.version }} steps: - - uses: googleapis/release-please-action@v4 + - name: DevOpsArrBOT token + id: DevOpsArrBOT + uses: getsentry/action-github-app-token@v3 + with: + app_id: '305652' + private_key: ${{ secrets.DEVOPSARRBOT_PRIVATE_KEY }} + + - name: Set DevOpsArrBOT config + run: | + git config --global user.name "devopsarr[bot]" + git config --global user.email "127950054+devopsarr[bot]@users.noreply.github.com" + + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ steps.DevOpsArrBOT.outputs.token }} + + - name: Release Please id: release + uses: googleapis/release-please-action@v4 + with: + token: ${{ steps.DevOpsArrBOT.outputs.token }} # ============================================ # Build and Push Multi-arch Image on Release @@ -89,9 +109,17 @@ jobs: permissions: contents: write steps: + - name: DevOpsArrBOT token + id: DevOpsArrBOT + uses: getsentry/action-github-app-token@v3 + with: + app_id: '305652' + private_key: ${{ secrets.DEVOPSARRBOT_PRIVATE_KEY }} + - uses: actions/checkout@v4 with: ref: main + token: ${{ steps.DevOpsArrBOT.outputs.token }} - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 @@ -114,8 +142,8 @@ jobs: - name: Commit and push docs run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git config user.name "devopsarr[bot]" + git config user.email "127950054+devopsarr[bot]@users.noreply.github.com" git add deploy/crds/ docs/api/ if git diff --cached --quiet; then echo "No documentation changes to commit" @@ -126,7 +154,7 @@ jobs: - name: Attach CRDs to release env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ steps.DevOpsArrBOT.outputs.token }} run: | tar czf crds.tar.gz -C deploy/crds . gh release upload ${{ needs.release-please.outputs.tag_name }} crds.tar.gz From 53ba38642b44a7ef869fee16462714fdc1c50f2c Mon Sep 17 00:00:00 2001 From: Fuochi Date: Sun, 31 May 2026 08:58:57 +0200 Subject: [PATCH 20/22] feat: move to helm release Co-authored-by: Copilot --- .github/workflows/cd.yml | 157 +++++++++++++++--- .github/workflows/ci.yml | 109 ++++++++---- .gitignore | 8 +- .release-please-manifest.json | 5 +- Makefile | 43 +++-- README.md | 53 +++--- charts/sonarr-operator/.helmignore | 20 +++ charts/sonarr-operator/Chart.yaml | 29 ++++ charts/sonarr-operator/README.md | 81 +++++++++ charts/sonarr-operator/artifacthub-repo.yml | 4 + charts/sonarr-operator/templates/NOTES.txt | 16 ++ charts/sonarr-operator/templates/_helpers.tpl | 62 +++++++ .../templates/clusterrole.yaml | 44 +++++ .../templates/clusterrolebinding.yaml | 16 ++ .../templates}/crds/autotag.yaml | 15 ++ .../templates}/crds/customformat.yaml | 15 ++ .../templates}/crds/delayprofile.yaml | 15 ++ .../templates}/crds/downloadclient.yaml | 15 ++ .../templates}/crds/downloadclientconfig.yaml | 15 ++ .../templates}/crds/importlist.yaml | 15 ++ .../templates}/crds/indexer.yaml | 15 ++ .../templates}/crds/indexerconfig.yaml | 15 ++ .../templates}/crds/languageprofile.yaml | 15 ++ .../crds/mediamanagementconfig.yaml | 15 ++ .../templates}/crds/metadata.yaml | 15 ++ .../templates}/crds/namingconfig.yaml | 15 ++ .../templates}/crds/notification.yaml | 15 ++ .../templates}/crds/qualitydefinition.yaml | 15 ++ .../templates}/crds/qualityprofile.yaml | 15 ++ .../templates}/crds/rootfolder.yaml | 15 ++ .../templates}/crds/series.yaml | 15 ++ .../templates}/crds/sonarr.yaml | 15 ++ .../sonarr-operator/templates}/crds/tag.yaml | 15 ++ .../sonarr-operator/templates/deployment.yaml | 74 +++++++++ .../templates/serviceaccount.yaml | 13 ++ charts/sonarr-operator/values.yaml | 97 +++++++++++ deploy/examples/sonarr-minimal.yaml | 40 +++++ release-please-config.json | 24 ++- src/bin/crdgen.rs | 87 ++++++++-- 39 files changed, 1163 insertions(+), 104 deletions(-) create mode 100644 charts/sonarr-operator/.helmignore create mode 100644 charts/sonarr-operator/Chart.yaml create mode 100644 charts/sonarr-operator/README.md create mode 100644 charts/sonarr-operator/artifacthub-repo.yml create mode 100644 charts/sonarr-operator/templates/NOTES.txt create mode 100644 charts/sonarr-operator/templates/_helpers.tpl create mode 100644 charts/sonarr-operator/templates/clusterrole.yaml create mode 100644 charts/sonarr-operator/templates/clusterrolebinding.yaml rename {deploy => charts/sonarr-operator/templates}/crds/autotag.yaml (95%) rename {deploy => charts/sonarr-operator/templates}/crds/customformat.yaml (94%) rename {deploy => charts/sonarr-operator/templates}/crds/delayprofile.yaml (94%) rename {deploy => charts/sonarr-operator/templates}/crds/downloadclient.yaml (96%) rename {deploy => charts/sonarr-operator/templates}/crds/downloadclientconfig.yaml (93%) rename {deploy => charts/sonarr-operator/templates}/crds/importlist.yaml (96%) rename {deploy => charts/sonarr-operator/templates}/crds/indexer.yaml (96%) rename {deploy => charts/sonarr-operator/templates}/crds/indexerconfig.yaml (93%) rename {deploy => charts/sonarr-operator/templates}/crds/languageprofile.yaml (95%) rename {deploy => charts/sonarr-operator/templates}/crds/mediamanagementconfig.yaml (95%) rename {deploy => charts/sonarr-operator/templates}/crds/metadata.yaml (94%) rename {deploy => charts/sonarr-operator/templates}/crds/namingconfig.yaml (94%) rename {deploy => charts/sonarr-operator/templates}/crds/notification.yaml (97%) rename {deploy => charts/sonarr-operator/templates}/crds/qualitydefinition.yaml (94%) rename {deploy => charts/sonarr-operator/templates}/crds/qualityprofile.yaml (95%) rename {deploy => charts/sonarr-operator/templates}/crds/rootfolder.yaml (92%) rename {deploy => charts/sonarr-operator/templates}/crds/series.yaml (95%) rename {deploy => charts/sonarr-operator/templates}/crds/sonarr.yaml (98%) rename {deploy => charts/sonarr-operator/templates}/crds/tag.yaml (92%) create mode 100644 charts/sonarr-operator/templates/deployment.yaml create mode 100644 charts/sonarr-operator/templates/serviceaccount.yaml create mode 100644 charts/sonarr-operator/values.yaml create mode 100644 deploy/examples/sonarr-minimal.yaml diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 79423b3..625dc9c 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -9,22 +9,38 @@ permissions: contents: write pull-requests: write packages: write + id-token: write # required for cosign keyless signing env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} + # OCI repository for the operator chart. Note: the GHCR namespace must + # already exist as a package; first push will create it. + CHART_OCI_REPO: oci://ghcr.io/${{ github.repository_owner }}/charts jobs: # ============================================ - # Release Please - Version Management + # Release Please - Multi-Package Version Management + # + # Two linked packages: + # - `.` (rust crate / operator binary) + # - `charts/sonarr-operator` (helm chart) + # Versions are kept in lockstep by the `linked-versions` plugin. # ============================================ release-please: name: Release Please runs-on: ubuntu-latest outputs: - release_created: ${{ steps.release.outputs.release_created }} - tag_name: ${{ steps.release.outputs.tag_name }} - version: ${{ steps.release.outputs.version }} + # Operator (root) package outputs + operator_released: ${{ steps.release.outputs['.--release_created'] }} + operator_tag: ${{ steps.release.outputs['.--tag_name'] }} + operator_version: ${{ steps.release.outputs['.--version'] }} + # Chart package outputs + chart_released: ${{ + steps.release.outputs['charts/sonarr-operator--release_created'] + }} + chart_tag: ${{ steps.release.outputs['charts/sonarr-operator--tag_name'] }} + chart_version: ${{ steps.release.outputs['charts/sonarr-operator--version'] }} steps: - name: DevOpsArrBOT token id: DevOpsArrBOT @@ -50,16 +66,17 @@ jobs: token: ${{ steps.DevOpsArrBOT.outputs.token }} # ============================================ - # Build and Push Multi-arch Image on Release + # Build, Push & Sign Multi-arch Operator Image # ============================================ build-and-push: name: Build & Push Multi-arch Image runs-on: ubuntu-latest needs: release-please - if: ${{ needs.release-please.outputs.release_created }} + if: ${{ needs.release-please.outputs.operator_released == 'true' }} permissions: contents: read packages: write + id-token: write steps: - uses: actions/checkout@v4 @@ -76,18 +93,22 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + - name: Install cosign + uses: sigstore/cosign-installer@v3 + - name: Docker meta id: meta uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | - type=semver,pattern=v{{version}},value=${{ needs.release-please.outputs.version }} - type=semver,pattern=v{{major}}.{{minor}},value=${{ needs.release-please.outputs.version }} - type=semver,pattern=v{{major}},value=${{ needs.release-please.outputs.version }} + type=semver,pattern=v{{version}},value=${{ needs.release-please.outputs.operator_version }} + type=semver,pattern=v{{major}}.{{minor}},value=${{ needs.release-please.outputs.operator_version }} + type=semver,pattern=v{{major}},value=${{ needs.release-please.outputs.operator_version }} type=raw,value=latest - name: Build and push multi-arch Docker image + id: build uses: docker/build-push-action@v5 with: context: . @@ -98,14 +119,98 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max + - name: Sign the image (keyless) + env: + COSIGN_EXPERIMENTAL: "true" + DIGEST: ${{ steps.build.outputs.digest }} + TAGS: ${{ steps.meta.outputs.tags }} + run: | + for tag in $TAGS; do + cosign sign --yes "${tag}@${DIGEST}" + done + # ============================================ - # Generate and Publish Docs & CRDs on Release + # Publish & Sign Operator Helm Chart (OCI) + # ============================================ + publish-chart: + name: Publish Helm Chart + runs-on: ubuntu-latest + needs: [ release-please, build-and-push ] + # `always() && ...` lets the chart publish even if build-and-push was + # skipped (chart-only release), but blocks publish if image build failed. + if: ${{ always() && needs.release-please.outputs.chart_released == 'true' && + (needs.build-and-push.result == 'success' || + needs.build-and-push.result == 'skipped') }} + permissions: + contents: write + packages: write + id-token: write + steps: + - name: DevOpsArrBOT token + id: DevOpsArrBOT + uses: getsentry/action-github-app-token@v3 + with: + app_id: '305652' + private_key: ${{ secrets.DEVOPSARRBOT_PRIVATE_KEY }} + + - uses: actions/checkout@v4 + with: + # Pull the tagged release commit so Chart.yaml has the right version. + ref: ${{ needs.release-please.outputs.chart_tag }} + token: ${{ steps.DevOpsArrBOT.outputs.token }} + + - uses: azure/setup-helm@v4 + - uses: sigstore/cosign-installer@v3 + + - name: Log in to GHCR for Helm + run: | + echo "${{ secrets.GITHUB_TOKEN }}" | \ + helm registry login ${{ env.REGISTRY }} \ + -u "${{ github.actor }}" --password-stdin + + - name: Package chart + id: package + run: | + helm package charts/sonarr-operator -d dist/ + pkg=$(ls dist/sonarr-operator-*.tgz) + echo "path=${pkg}" >> "$GITHUB_OUTPUT" + echo "name=$(basename ${pkg})" >> "$GITHUB_OUTPUT" + + - name: Push chart to GHCR + id: push + run: | + out=$(helm push "${{ steps.package.outputs.path }}" "${{ env.CHART_OCI_REPO }}" 2>&1) + echo "$out" + digest=$(echo "$out" | awk '/Digest:/ {print $2}') + echo "digest=${digest}" >> "$GITHUB_OUTPUT" + + - name: Sign chart (keyless) + env: + COSIGN_EXPERIMENTAL: "true" + run: | + cosign sign --yes \ + "${{ env.REGISTRY }}/${{ github.repository_owner }}/charts/sonarr-operator@${{ steps.push.outputs.digest }}" + + - name: Attach chart tarball to GH release + env: + GITHUB_TOKEN: ${{ steps.DevOpsArrBOT.outputs.token }} + run: | + gh release upload ${{ needs.release-please.outputs.chart_tag }} \ + "${{ steps.package.outputs.path }}" + + # ============================================ + # Regenerate Docs & CRDs and Attach to Release + # + # Runs on operator release: regenerates the helm-templated CRDs in the + # chart (source of truth), derives the plain CRDs + docs from them, + # commits chart + docs back, and attaches plain CRDs to the GH release + # for users who don't use Helm. # ============================================ publish-docs: name: Publish Docs & CRDs runs-on: ubuntu-latest needs: release-please - if: ${{ needs.release-please.outputs.release_created }} + if: ${{ needs.release-please.outputs.operator_released == 'true' }} permissions: contents: write steps: @@ -128,27 +233,38 @@ jobs: with: go-version: '1.22' - - name: Generate CRDs + - uses: azure/setup-helm@v4 + + - name: Generate helm-templated CRDs into the chart + run: | + rm -f charts/sonarr-operator/templates/crds/*.yaml + cargo run --bin crdgen -- --split charts/sonarr-operator/templates/crds --helm + + - name: Render plain CRDs (for docs and release asset) run: | - mkdir -p deploy/crds - rm -f deploy/crds/*.yaml - cargo run --bin crdgen -- --split deploy/crds + mkdir -p artifacts/crds + helm template sonarr-operator charts/sonarr-operator \ + --namespace sonarr-operator-system \ + --set crds.install=true \ + --set crds.keep=false \ + --show-only 'templates/crds/*.yaml' \ + > artifacts/crds/all.yaml - name: Generate documentation run: | go install fybrik.io/crdoc@latest mkdir -p docs/api - crdoc --resources deploy/crds --output docs/api/crd-reference.md + crdoc --resources artifacts/crds --output docs/api/crd-reference.md - name: Commit and push docs run: | git config user.name "devopsarr[bot]" git config user.email "127950054+devopsarr[bot]@users.noreply.github.com" - git add deploy/crds/ docs/api/ + git add charts/sonarr-operator/templates/crds/ docs/api/ if git diff --cached --quiet; then echo "No documentation changes to commit" else - git commit -m "docs: update CRDs and documentation for ${{ needs.release-please.outputs.tag_name }}" + git commit -m "docs: update CRDs and documentation for ${{ needs.release-please.outputs.operator_tag }}" git push fi @@ -156,5 +272,6 @@ jobs: env: GITHUB_TOKEN: ${{ steps.DevOpsArrBOT.outputs.token }} run: | - tar czf crds.tar.gz -C deploy/crds . - gh release upload ${{ needs.release-please.outputs.tag_name }} crds.tar.gz + cp artifacts/crds/all.yaml crds.yaml + tar czf crds.tar.gz -C artifacts/crds . + gh release upload ${{ needs.release-please.outputs.operator_tag }} crds.tar.gz crds.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6074f4e..5c995de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [main] + branches: [ main ] pull_request: - branches: [main] + branches: [ main ] concurrency: group: ci-${{ github.ref }} @@ -49,22 +49,39 @@ jobs: # ============================================ # Generate CRDs (shared artifact) + # + # The chart's templates/crds/ directory is the source of truth. We + # regenerate it here and also publish a plain (un-templated) copy for + # consumers that don't run helm (integration/e2e tests, docs). # ============================================ generate-crds: name: Generate CRDs runs-on: ubuntu-latest - needs: [lint] + needs: [ lint ] steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - - name: Generate CRDs - run: cargo run --bin crdgen -- --split deploy/crds + - uses: azure/setup-helm@v4 + - name: Generate helm-templated CRDs into the chart + run: cargo run --bin crdgen -- --split charts/sonarr-operator/templates/crds + --helm + - name: Render plain CRDs for downstream consumers + run: | + mkdir -p artifacts/crds + # `helm template` with crds.install=true strips the Helm guard + # and produces vanilla CRDs that can be `kubectl apply`-ed. + helm template sonarr-operator charts/sonarr-operator \ + --namespace sonarr-operator-system \ + --set crds.install=true \ + --set crds.keep=false \ + --show-only 'templates/crds/*.yaml' \ + > artifacts/crds/all.yaml - name: Upload CRDs artifact uses: actions/upload-artifact@v4 with: name: crds - path: deploy/crds/ + path: artifacts/crds/ # ============================================ # Generate CRD Documentation @@ -72,7 +89,7 @@ jobs: docs: name: Generate Docs runs-on: ubuntu-latest - needs: [generate-crds] + needs: [ generate-crds ] steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 @@ -82,26 +99,58 @@ jobs: uses: actions/download-artifact@v4 with: name: crds - path: deploy/crds/ + path: artifacts/crds/ - name: Install crdoc run: go install fybrik.io/crdoc@latest - name: Generate CRD documentation run: | mkdir -p docs/api - crdoc --resources deploy/crds --output docs/api/crd-reference.md + crdoc --resources artifacts/crds --output docs/api/crd-reference.md - name: Upload documentation artifact uses: actions/upload-artifact@v4 with: name: docs path: docs/api/ + # ============================================ + # Helm Chart Lint & Template (Phase 2) + # ============================================ + chart-lint: + name: Helm Chart Lint + runs-on: ubuntu-latest + needs: [ lint ] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - uses: azure/setup-helm@v4 + - name: Regenerate helm-wrapped CRDs into the chart + run: cargo run --bin crdgen -- --split charts/sonarr-operator/templates/crds + --helm + - name: Fail on CRD drift + run: | + if ! git diff --quiet -- charts/sonarr-operator/templates/crds; then + echo "::error::Chart CRDs are out of sync with the Rust types." + echo "Run: cargo run --bin crdgen -- --split charts/sonarr-operator/templates/crds --helm" + git diff -- charts/sonarr-operator/templates/crds | head -200 + exit 1 + fi + - name: helm lint + run: helm lint charts/sonarr-operator + - name: helm template (defaults) + run: helm template sonarr-operator charts/sonarr-operator --namespace + sonarr-operator-system + - name: helm template (crds.install=false) + run: helm template sonarr-operator charts/sonarr-operator --namespace + sonarr-operator-system --set crds.install=false + # ============================================ # Build and Push Docker Image # ============================================ build-image: name: Build & Push Image runs-on: ubuntu-latest - needs: [lint] + needs: [ lint ] permissions: contents: read packages: write @@ -150,11 +199,11 @@ jobs: integration-test: name: Integration Tests (K8s ${{ matrix.k8s }}) runs-on: ubuntu-latest - needs: [generate-crds] + needs: [ generate-crds ] strategy: fail-fast: false matrix: - k8s: [v1.28, v1.29, latest] + k8s: [ v1.28, v1.29, latest ] steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable @@ -166,17 +215,18 @@ jobs: uses: actions/download-artifact@v4 with: name: crds - path: deploy/crds/ + path: artifacts/crds/ - name: Set up k3d cluster uses: nolar/setup-k3d-k3s@v1 with: version: ${{ matrix.k8s }} k3d-name: sonarr-operator-test - k3d-args: '--no-lb --no-rollback --k3s-arg --disable=traefik,servicelb,metrics-server@server:*' + k3d-args: '--no-lb --no-rollback --k3s-arg + --disable=traefik,servicelb,metrics-server@server:*' - name: Install CRDs - run: kubectl apply -f deploy/crds/ + run: kubectl apply -f artifacts/crds/ - name: Wait for CRDs run: | @@ -205,7 +255,7 @@ jobs: name: E2E Tests runs-on: ubuntu-latest timeout-minutes: 20 - needs: [generate-crds, build-image] + needs: [ generate-crds, build-image ] permissions: contents: read packages: read @@ -215,12 +265,13 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 + - uses: azure/setup-helm@v4 - name: Download CRDs artifact uses: actions/download-artifact@v4 with: name: crds - path: deploy/crds/ + path: artifacts/crds/ - name: Log in to GitHub Container Registry uses: docker/login-action@v3 @@ -230,7 +281,8 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Pull operator image - run: docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.build-image.outputs.short-sha }} + run: docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ + needs.build-image.outputs.short-sha }} - name: Install k3d run: curl -s https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | bash @@ -247,9 +299,14 @@ jobs: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.build-image.outputs.short-sha }} \ --cluster sonarr-e2e - - name: Install CRDs + - name: Install operator via Helm chart run: | - kubectl apply -f deploy/crds/ + helm install sonarr-operator charts/sonarr-operator \ + --namespace sonarr-operator-system \ + --create-namespace \ + --set image.repository=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} \ + --set image.tag=${{ needs.build-image.outputs.short-sha }} \ + --wait --timeout=3m for crd in sonarrs sonarrtags sonarrrootfolders sonarrindexers \ sonarrdownloadclients sonarrnotifications \ sonarrqualityprofiles sonarrseries sonarrautotags \ @@ -260,16 +317,6 @@ jobs: sonarrnamingconfigs sonarrqualitydefinitions; do kubectl wait --for=condition=established "crd/${crd}.devopsarr.io" --timeout=60s done - - - name: Deploy operator - run: | - kubectl apply -f deploy/namespace.yaml - kubectl apply -f deploy/rbac.yaml - # Patch the deployment image to the one we just built - sed 's|image: .*|image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.build-image.outputs.short-sha }}|' \ - deploy/deployment.yaml | kubectl apply -f - - kubectl wait --for=condition=available deployment/sonarr-operator \ - -n sonarr-operator-system --timeout=120s echo "Operator is running" - name: Create API key secret @@ -313,7 +360,7 @@ jobs: if: failure() run: | echo "=== Operator logs ===" - kubectl logs deployment/sonarr-operator -n sonarr-operator-system --tail=200 || echo "No operator logs" + kubectl logs -n sonarr-operator-system -l app.kubernetes.io/name=sonarr-operator --tail=200 || echo "No operator logs" echo "" echo "=== Sonarr pod logs ===" kubectl logs -l app.kubernetes.io/name=sonarr -n default --all-containers --tail=100 || echo "No Sonarr logs" diff --git a/.gitignore b/.gitignore index eac99f1..508940f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,10 @@ /target/ **/*.rs.bk +# Local build/render output +/dist/ +/artifacts/ + # IDE and editor files .idea/ .vscode/ @@ -52,5 +56,5 @@ tarpaulin-report.html /doc/ # Binary artifacts (if accidentally placed in root) -sonarr-operator -crdgen +/sonarr-operator +/crdgen diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 0967ef4..af53bd7 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1 +1,4 @@ -{} +{ + ".": "0.1.0", + "charts/sonarr-operator": "0.1.0" +} diff --git a/Makefile b/Makefile index 7a91442..619348d 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,9 @@ # Configuration BINARY_NAME := sonarr-operator -CRD_DIR := deploy/crds +CHART_DIR := charts/sonarr-operator +CRD_DIR := $(CHART_DIR)/templates/crds +RENDERED_CRDS := dist/crds.yaml NAMESPACE := sonarr-operator-system K3D_CLUSTER := sonarr-e2e E2E_API_KEY ?= test-e2e-api-key-12345 @@ -77,18 +79,28 @@ integration-test-verbose: install ## Run integration tests with verbose output ##@ CRD Management .PHONY: crds -crds: ## Generate CRD manifests +crds: ## Generate helm-templated CRDs into the chart @mkdir -p $(CRD_DIR) @rm -f $(CRD_DIR)/*.yaml - cargo run --bin crdgen -- --split $(CRD_DIR) + cargo run --bin crdgen -- --split $(CRD_DIR) --helm + +.PHONY: crds-render +crds-render: crds ## Render plain CRDs from the chart (for kubectl apply) + @mkdir -p $(dir $(RENDERED_CRDS)) + helm template sonarr-operator $(CHART_DIR) \ + --namespace $(NAMESPACE) \ + --set crds.install=true \ + --set crds.keep=false \ + --show-only 'templates/crds/*.yaml' \ + > $(RENDERED_CRDS) .PHONY: install -install: crds ## Install CRDs to cluster - kubectl apply -f $(CRD_DIR)/ +install: crds-render ## Install CRDs to cluster + kubectl apply -f $(RENDERED_CRDS) .PHONY: uninstall uninstall: ## Remove CRDs from cluster - kubectl delete -f $(CRD_DIR)/ --ignore-not-found + kubectl delete -f $(RENDERED_CRDS) --ignore-not-found ##@ Documentation @@ -96,13 +108,13 @@ DOCS_DIR := docs/api GOBIN := $(shell go env GOPATH)/bin .PHONY: docs -docs: crds ## Generate CRD documentation +docs: crds-render ## Generate CRD documentation @mkdir -p $(DOCS_DIR) @if ! command -v crdoc >/dev/null 2>&1 && [ ! -f "$(GOBIN)/crdoc" ]; then \ echo "Installing crdoc..."; \ go install fybrik.io/crdoc@latest; \ fi - @PATH="$(GOBIN):$$PATH" crdoc --resources $(CRD_DIR) --output $(DOCS_DIR)/crd-reference.md + @PATH="$(GOBIN):$$PATH" crdoc --resources $(RENDERED_CRDS) --output $(DOCS_DIR)/crd-reference.md @echo "Documentation generated in $(DOCS_DIR)/crd-reference.md" ##@ Running @@ -125,16 +137,17 @@ docker: ## Build Docker image and import into k3d cluster ##@ Kubernetes Deployment .PHONY: deploy -deploy: install docker ## Deploy operator to cluster - kubectl apply -f deploy/namespace.yaml - kubectl apply -f deploy/rbac.yaml - sed 's|image: .*|image: $(BINARY_NAME):latest|' deploy/deployment.yaml | kubectl apply -f - +deploy: docker ## Deploy operator (chart) to cluster with local image + helm upgrade --install sonarr-operator $(CHART_DIR) \ + --namespace $(NAMESPACE) --create-namespace \ + --set image.repository=$(BINARY_NAME) \ + --set image.tag=latest \ + --set image.pullPolicy=Never \ + --wait .PHONY: undeploy undeploy: ## Remove operator from cluster - kubectl delete -f deploy/deployment.yaml --ignore-not-found - kubectl delete -f deploy/rbac.yaml --ignore-not-found - kubectl delete -f deploy/namespace.yaml --ignore-not-found + helm uninstall sonarr-operator -n $(NAMESPACE) --ignore-not-found ##@ Local E2E Testing (k3d) # diff --git a/README.md b/README.md index 0e50f31..847147b 100644 --- a/README.md +++ b/README.md @@ -59,49 +59,54 @@ For detailed API specifications, see the [CRD Reference](docs/api/crd-reference. ### Prerequisites - Kubernetes cluster (1.28+) -- [k3d](https://k3d.io/) for local development -- Rust toolchain (1.88+) for building from source +- [Helm](https://helm.sh/) 3.8+ (for OCI registry support) +- [k3d](https://k3d.io/) for local development (optional) -### Installation +### Installation (Helm — recommended) -1. **Install CRDs** +Install the operator and its CRDs from the OCI chart on GHCR: ```bash -kubectl apply -f deploy/crds/ +helm install sonarr-operator \ + oci://ghcr.io/devopsarr/charts/sonarr-operator \ + --namespace sonarr-operator-system \ + --create-namespace ``` -2. **Deploy the Operator** +The chart installs the operator Deployment, RBAC, and all 19 CRDs. CRDs are annotated `helm.sh/resource-policy: keep`, so they (and any `Sonarr` resources) survive a `helm uninstall`. + +To install a specific version, pass `--version `. To skip CRD installation (when managing them out-of-band), pass `--set crds.install=false`. To allow `helm uninstall` to remove CRDs as well, pass `--set crds.keep=false`. See the [chart README](charts/sonarr-operator/README.md) for the full values reference. + +### Installation (raw manifests — alternative) + +Render the chart locally or use the per-release CRD asset: ```bash +# Option A: render from chart source +helm template sonarr-operator charts/sonarr-operator \ + --namespace sonarr-operator-system | kubectl apply -f - + +# Option B: download CRDs from a release and apply the static deploy/ manifests +kubectl apply -f https://github.com/devopsarr/k8s-operator-sonarr/releases/latest/download/crds.yaml kubectl apply -f deploy/namespace.yaml kubectl apply -f deploy/rbac.yaml kubectl apply -f deploy/deployment.yaml ``` -3. **Create a Sonarr Instance Reference** +### Create a Sonarr instance -First, create a secret with your Sonarr API key: +A minimal example lives at [deploy/examples/sonarr-minimal.yaml](deploy/examples/sonarr-minimal.yaml). ```bash +# Optional: pre-create an API key Secret (omit to have the operator generate one) kubectl create secret generic sonarr-api-key \ - --from-literal=api-key=YOUR_SONARR_API_KEY -``` - -Then create the Sonarr instance reference: + --from-literal=api-key="$(openssl rand -hex 16)" -```yaml -apiVersion: devopsarr.io/v1alpha1 -kind: Sonarr -metadata: - name: my-sonarr -spec: - url: "http://sonarr.example.com:8989" - apiKeySecretRef: - name: sonarr-api-key - key: api-key +kubectl apply -f deploy/examples/sonarr-minimal.yaml +kubectl wait sonarr/sonarr --for=condition=Ready --timeout=5m ``` -4. **Create Resources** +### Create child resources ```yaml apiVersion: devopsarr.io/v1alpha1 @@ -110,7 +115,7 @@ metadata: name: anime spec: sonarrInstanceRef: - name: my-sonarr + name: sonarr label: "anime" --- apiVersion: devopsarr.io/v1alpha1 diff --git a/charts/sonarr-operator/.helmignore b/charts/sonarr-operator/.helmignore new file mode 100644 index 0000000..9fc7a8a --- /dev/null +++ b/charts/sonarr-operator/.helmignore @@ -0,0 +1,20 @@ +# Patterns to ignore when building Helm packages. +.DS_Store +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +*.swp +*.bak +*.tmp +*.orig +*~ +.idea/ +.vscode/ +.project +.tox/ +.flake8 +.pytest_cache/ diff --git a/charts/sonarr-operator/Chart.yaml b/charts/sonarr-operator/Chart.yaml new file mode 100644 index 0000000..48e68d7 --- /dev/null +++ b/charts/sonarr-operator/Chart.yaml @@ -0,0 +1,29 @@ +apiVersion: v2 +name: sonarr-operator +description: A Kubernetes operator for managing Sonarr instances and their + configuration via Custom Resources +type: application +# Chart version is bumped lockstep with the operator binary by release-please +# (linked-versions plugin). `appVersion` tracks the operator's crate version. +version: 0.1.0 +appVersion: "0.1.0" # x-release-please-version +kubeVersion: ">=1.28.0-0" +home: https://github.com/devopsarr/k8s-operator-sonarr +sources: + - https://github.com/devopsarr/k8s-operator-sonarr +maintainers: + - name: DevOpsArr + url: https://github.com/devopsarr +keywords: + - sonarr + - operator + - kubernetes + - kube-rs + - media + - arr +annotations: + artifacthub.io/category: integration-delivery + artifacthub.io/license: GPL-3.0 + artifacthub.io/operator: "true" + artifacthub.io/operatorCapabilities: Basic Install + artifacthub.io/prerelease: "true" diff --git a/charts/sonarr-operator/README.md b/charts/sonarr-operator/README.md new file mode 100644 index 0000000..3ef9c21 --- /dev/null +++ b/charts/sonarr-operator/README.md @@ -0,0 +1,81 @@ +# sonarr-operator + +A Kubernetes operator that manages [Sonarr](https://sonarr.tv/) instances and their configuration declaratively through Custom Resources. + +This chart installs the operator Deployment, RBAC, and (optionally) the CRDs that the operator reconciles. + +## TL;DR + +```bash +helm install sonarr-operator \ + oci://ghcr.io/devopsarr/charts/sonarr-operator \ + --namespace sonarr-operator-system \ + --create-namespace +``` + +## Prerequisites + +- Kubernetes >= 1.28 +- Helm >= 3.8 (OCI support) + +## Installing the chart + +```bash +helm install sonarr-operator \ + oci://ghcr.io/devopsarr/charts/sonarr-operator \ + --version \ + --namespace sonarr-operator-system \ + --create-namespace +``` + +The chart ships **19 CRDs** under the `devopsarr.io/v1alpha1` API group. They are installed by default and annotated `helm.sh/resource-policy: keep`, so they (and your `Sonarr` resources) survive a `helm uninstall`. + +If you manage CRDs out-of-band (for example with a separate Flux/ArgoCD application), disable them: + +```bash +helm install sonarr-operator oci://ghcr.io/devopsarr/charts/sonarr-operator \ + --set crds.install=false +``` + +To allow `helm uninstall` to remove CRDs as well, opt out of the `keep` policy: + +```bash +helm install sonarr-operator oci://ghcr.io/devopsarr/charts/sonarr-operator \ + --set crds.keep=false +``` + +## Uninstalling the chart + +```bash +helm uninstall sonarr-operator -n sonarr-operator-system +``` + +CRDs are intentionally **not** removed by `helm uninstall`. To purge them and all managed resources: + +```bash +kubectl get crd -o name | grep devopsarr.io | xargs kubectl delete +``` + +## Values + +See [`values.yaml`](./values.yaml) for the full set of values with inline documentation. + +| Key | Default | Description | +|---|---|---| +| `replicaCount` | `1` | Number of operator replicas. | +| `image.repository` | `ghcr.io/devopsarr/k8s-operator-sonarr` | Operator image. | +| `image.tag` | `""` | Image tag. Defaults to `Chart.AppVersion`. | +| `image.pullPolicy` | `IfNotPresent` | Image pull policy. | +| `crds.install` | `true` | Install the operator's CRDs as part of the chart. | +| `crds.keep` | `true` | Annotate CRDs with `helm.sh/resource-policy: keep`. | +| `crds.annotations` | `{}` | Extra annotations merged into each CRD. | +| `crds.additionalLabels` | `{}` | Extra labels merged into each CRD. | +| `serviceAccount.create` | `true` | Create a ServiceAccount for the operator. | +| `rbac.create` | `true` | Create ClusterRole and ClusterRoleBinding. | +| `logLevel` | `info,sonarr_operator=debug` | `RUST_LOG` value. | +| `resources` | requests `50m`/`64Mi`, limits `200m`/`256Mi` | Container resources. | + +## Source + +- Operator: +- CRD reference: diff --git a/charts/sonarr-operator/artifacthub-repo.yml b/charts/sonarr-operator/artifacthub-repo.yml new file mode 100644 index 0000000..e6a7e4e --- /dev/null +++ b/charts/sonarr-operator/artifacthub-repo.yml @@ -0,0 +1,4 @@ +repositoryID: "" +owners: + - name: DevOpsArr + email: devopsarr@users.noreply.github.com diff --git a/charts/sonarr-operator/templates/NOTES.txt b/charts/sonarr-operator/templates/NOTES.txt new file mode 100644 index 0000000..ce803b2 --- /dev/null +++ b/charts/sonarr-operator/templates/NOTES.txt @@ -0,0 +1,16 @@ +{{ .Chart.Name | upper }} {{ .Chart.Version }} installed in namespace {{ .Release.Namespace }}. + +Operator image: {{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }} + +Next steps: + 1. Verify the operator is running: + kubectl -n {{ .Release.Namespace }} rollout status deploy/{{ include "sonarr-operator.fullname" . }} + + 2. Create a Sonarr instance. A minimal example is available at: + https://github.com/devopsarr/k8s-operator-sonarr/blob/main/deploy/examples/sonarr-minimal.yaml + + 3. Apply it (after creating the referenced API key Secret): + kubectl apply -f sonarr-minimal.yaml + kubectl wait sonarr/sonarr --for=condition=Ready --timeout=5m + +CRD installation: {{ if .Values.crds.install }}enabled{{ if .Values.crds.keep }} (annotated `helm.sh/resource-policy: keep`){{ end }}.{{ else }}DISABLED — make sure CRDs are installed out-of-band before applying any Sonarr CR.{{ end }} diff --git a/charts/sonarr-operator/templates/_helpers.tpl b/charts/sonarr-operator/templates/_helpers.tpl new file mode 100644 index 0000000..c528dc7 --- /dev/null +++ b/charts/sonarr-operator/templates/_helpers.tpl @@ -0,0 +1,62 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "sonarr-operator.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a fully qualified app name. +*/}} +{{- define "sonarr-operator.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Chart name + version label. +*/}} +{{- define "sonarr-operator.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels. +*/}} +{{- define "sonarr-operator.labels" -}} +helm.sh/chart: {{ include "sonarr-operator.chart" . }} +{{ include "sonarr-operator.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +app.kubernetes.io/component: operator +app.kubernetes.io/part-of: sonarr-operator +{{- end }} + +{{/* +Selector labels. +*/}} +{{- define "sonarr-operator.selectorLabels" -}} +app.kubernetes.io/name: {{ include "sonarr-operator.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Name of the ServiceAccount to use. +*/}} +{{- define "sonarr-operator.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "sonarr-operator.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/charts/sonarr-operator/templates/clusterrole.yaml b/charts/sonarr-operator/templates/clusterrole.yaml new file mode 100644 index 0000000..ba96ccf --- /dev/null +++ b/charts/sonarr-operator/templates/clusterrole.yaml @@ -0,0 +1,44 @@ +{{- if .Values.rbac.create -}} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ include "sonarr-operator.fullname" . }} + labels: + {{- include "sonarr-operator.labels" . | nindent 4 }} +rules: + # Core API resources + - apiGroups: [""] + resources: ["secrets"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: [""] + resources: ["services"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: [""] + resources: ["persistentvolumeclaims"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: [""] + resources: ["events"] + verbs: ["create", "patch"] + # Apps API + - apiGroups: ["apps"] + resources: ["deployments"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + # Networking API (Ingress) + - apiGroups: ["networking.k8s.io"] + resources: ["ingresses"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + # Gateway API (HTTPRoute) + - apiGroups: ["gateway.networking.k8s.io"] + resources: ["httproutes"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + # Sonarr CRDs + - apiGroups: ["devopsarr.io"] + resources: ["*"] + verbs: ["*"] + - apiGroups: ["devopsarr.io"] + resources: ["*/status"] + verbs: ["get", "patch", "update"] + - apiGroups: ["devopsarr.io"] + resources: ["*/finalizers"] + verbs: ["update"] +{{- end }} diff --git a/charts/sonarr-operator/templates/clusterrolebinding.yaml b/charts/sonarr-operator/templates/clusterrolebinding.yaml new file mode 100644 index 0000000..80dd487 --- /dev/null +++ b/charts/sonarr-operator/templates/clusterrolebinding.yaml @@ -0,0 +1,16 @@ +{{- if .Values.rbac.create -}} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ include "sonarr-operator.fullname" . }} + labels: + {{- include "sonarr-operator.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ include "sonarr-operator.fullname" . }} +subjects: + - kind: ServiceAccount + name: {{ include "sonarr-operator.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} +{{- end }} diff --git a/deploy/crds/autotag.yaml b/charts/sonarr-operator/templates/crds/autotag.yaml similarity index 95% rename from deploy/crds/autotag.yaml rename to charts/sonarr-operator/templates/crds/autotag.yaml index f70a9f8..e8ca6e7 100644 --- a/deploy/crds/autotag.yaml +++ b/charts/sonarr-operator/templates/crds/autotag.yaml @@ -1,6 +1,20 @@ +{{- if .Values.crds.install }} apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: + {{- if or .Values.crds.keep .Values.crds.annotations }} + annotations: + {{- if .Values.crds.keep }} + helm.sh/resource-policy: keep + {{- end }} + {{- with .Values.crds.annotations }} +{{ toYaml . | indent 4 }} + {{- end }} + {{- end }} + {{- with .Values.crds.additionalLabels }} + labels: +{{ toYaml . | indent 4 }} + {{- end }} name: sonarrautotags.devopsarr.io spec: group: devopsarr.io @@ -180,3 +194,4 @@ spec: storage: true subresources: status: {} +{{- end }} diff --git a/deploy/crds/customformat.yaml b/charts/sonarr-operator/templates/crds/customformat.yaml similarity index 94% rename from deploy/crds/customformat.yaml rename to charts/sonarr-operator/templates/crds/customformat.yaml index f6395f8..8ba3510 100644 --- a/deploy/crds/customformat.yaml +++ b/charts/sonarr-operator/templates/crds/customformat.yaml @@ -1,6 +1,20 @@ +{{- if .Values.crds.install }} apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: + {{- if or .Values.crds.keep .Values.crds.annotations }} + annotations: + {{- if .Values.crds.keep }} + helm.sh/resource-policy: keep + {{- end }} + {{- with .Values.crds.annotations }} +{{ toYaml . | indent 4 }} + {{- end }} + {{- end }} + {{- with .Values.crds.additionalLabels }} + labels: +{{ toYaml . | indent 4 }} + {{- end }} name: sonarrcustomformats.devopsarr.io spec: group: devopsarr.io @@ -171,3 +185,4 @@ spec: storage: true subresources: status: {} +{{- end }} diff --git a/deploy/crds/delayprofile.yaml b/charts/sonarr-operator/templates/crds/delayprofile.yaml similarity index 94% rename from deploy/crds/delayprofile.yaml rename to charts/sonarr-operator/templates/crds/delayprofile.yaml index c7fcea8..ac8d7eb 100644 --- a/deploy/crds/delayprofile.yaml +++ b/charts/sonarr-operator/templates/crds/delayprofile.yaml @@ -1,6 +1,20 @@ +{{- if .Values.crds.install }} apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: + {{- if or .Values.crds.keep .Values.crds.annotations }} + annotations: + {{- if .Values.crds.keep }} + helm.sh/resource-policy: keep + {{- end }} + {{- with .Values.crds.annotations }} +{{ toYaml . | indent 4 }} + {{- end }} + {{- end }} + {{- with .Values.crds.additionalLabels }} + labels: +{{ toYaml . | indent 4 }} + {{- end }} name: sonarrdelayprofiles.devopsarr.io spec: group: devopsarr.io @@ -163,3 +177,4 @@ spec: storage: true subresources: status: {} +{{- end }} diff --git a/deploy/crds/downloadclient.yaml b/charts/sonarr-operator/templates/crds/downloadclient.yaml similarity index 96% rename from deploy/crds/downloadclient.yaml rename to charts/sonarr-operator/templates/crds/downloadclient.yaml index 45d388d..e27aab2 100644 --- a/deploy/crds/downloadclient.yaml +++ b/charts/sonarr-operator/templates/crds/downloadclient.yaml @@ -1,6 +1,20 @@ +{{- if .Values.crds.install }} apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: + {{- if or .Values.crds.keep .Values.crds.annotations }} + annotations: + {{- if .Values.crds.keep }} + helm.sh/resource-policy: keep + {{- end }} + {{- with .Values.crds.annotations }} +{{ toYaml . | indent 4 }} + {{- end }} + {{- end }} + {{- with .Values.crds.additionalLabels }} + labels: +{{ toYaml . | indent 4 }} + {{- end }} name: sonarrdownloadclients.devopsarr.io spec: group: devopsarr.io @@ -287,3 +301,4 @@ spec: storage: true subresources: status: {} +{{- end }} diff --git a/deploy/crds/downloadclientconfig.yaml b/charts/sonarr-operator/templates/crds/downloadclientconfig.yaml similarity index 93% rename from deploy/crds/downloadclientconfig.yaml rename to charts/sonarr-operator/templates/crds/downloadclientconfig.yaml index 7f1cca0..a27b15e 100644 --- a/deploy/crds/downloadclientconfig.yaml +++ b/charts/sonarr-operator/templates/crds/downloadclientconfig.yaml @@ -1,6 +1,20 @@ +{{- if .Values.crds.install }} apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: + {{- if or .Values.crds.keep .Values.crds.annotations }} + annotations: + {{- if .Values.crds.keep }} + helm.sh/resource-policy: keep + {{- end }} + {{- with .Values.crds.annotations }} +{{ toYaml . | indent 4 }} + {{- end }} + {{- end }} + {{- with .Values.crds.additionalLabels }} + labels: +{{ toYaml . | indent 4 }} + {{- end }} name: sonarrdownloadclientconfigs.devopsarr.io spec: group: devopsarr.io @@ -119,3 +133,4 @@ spec: storage: true subresources: status: {} +{{- end }} diff --git a/deploy/crds/importlist.yaml b/charts/sonarr-operator/templates/crds/importlist.yaml similarity index 96% rename from deploy/crds/importlist.yaml rename to charts/sonarr-operator/templates/crds/importlist.yaml index 1f1bc48..7075608 100644 --- a/deploy/crds/importlist.yaml +++ b/charts/sonarr-operator/templates/crds/importlist.yaml @@ -1,6 +1,20 @@ +{{- if .Values.crds.install }} apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: + {{- if or .Values.crds.keep .Values.crds.annotations }} + annotations: + {{- if .Values.crds.keep }} + helm.sh/resource-policy: keep + {{- end }} + {{- with .Values.crds.annotations }} +{{ toYaml . | indent 4 }} + {{- end }} + {{- end }} + {{- with .Values.crds.additionalLabels }} + labels: +{{ toYaml . | indent 4 }} + {{- end }} name: sonarrimportlists.devopsarr.io spec: group: devopsarr.io @@ -260,3 +274,4 @@ spec: storage: true subresources: status: {} +{{- end }} diff --git a/deploy/crds/indexer.yaml b/charts/sonarr-operator/templates/crds/indexer.yaml similarity index 96% rename from deploy/crds/indexer.yaml rename to charts/sonarr-operator/templates/crds/indexer.yaml index 172ac7d..e93b88a 100644 --- a/deploy/crds/indexer.yaml +++ b/charts/sonarr-operator/templates/crds/indexer.yaml @@ -1,6 +1,20 @@ +{{- if .Values.crds.install }} apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: + {{- if or .Values.crds.keep .Values.crds.annotations }} + annotations: + {{- if .Values.crds.keep }} + helm.sh/resource-policy: keep + {{- end }} + {{- with .Values.crds.annotations }} +{{ toYaml . | indent 4 }} + {{- end }} + {{- end }} + {{- with .Values.crds.additionalLabels }} + labels: +{{ toYaml . | indent 4 }} + {{- end }} name: sonarrindexers.devopsarr.io spec: group: devopsarr.io @@ -252,3 +266,4 @@ spec: storage: true subresources: status: {} +{{- end }} diff --git a/deploy/crds/indexerconfig.yaml b/charts/sonarr-operator/templates/crds/indexerconfig.yaml similarity index 93% rename from deploy/crds/indexerconfig.yaml rename to charts/sonarr-operator/templates/crds/indexerconfig.yaml index 8e341d5..b84f7a1 100644 --- a/deploy/crds/indexerconfig.yaml +++ b/charts/sonarr-operator/templates/crds/indexerconfig.yaml @@ -1,6 +1,20 @@ +{{- if .Values.crds.install }} apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: + {{- if or .Values.crds.keep .Values.crds.annotations }} + annotations: + {{- if .Values.crds.keep }} + helm.sh/resource-policy: keep + {{- end }} + {{- with .Values.crds.annotations }} +{{ toYaml . | indent 4 }} + {{- end }} + {{- end }} + {{- with .Values.crds.additionalLabels }} + labels: +{{ toYaml . | indent 4 }} + {{- end }} name: sonarrindexerconfigs.devopsarr.io spec: group: devopsarr.io @@ -123,3 +137,4 @@ spec: storage: true subresources: status: {} +{{- end }} diff --git a/deploy/crds/languageprofile.yaml b/charts/sonarr-operator/templates/crds/languageprofile.yaml similarity index 95% rename from deploy/crds/languageprofile.yaml rename to charts/sonarr-operator/templates/crds/languageprofile.yaml index 01c5abe..56cd8aa 100644 --- a/deploy/crds/languageprofile.yaml +++ b/charts/sonarr-operator/templates/crds/languageprofile.yaml @@ -1,6 +1,20 @@ +{{- if .Values.crds.install }} apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: + {{- if or .Values.crds.keep .Values.crds.annotations }} + annotations: + {{- if .Values.crds.keep }} + helm.sh/resource-policy: keep + {{- end }} + {{- with .Values.crds.annotations }} +{{ toYaml . | indent 4 }} + {{- end }} + {{- end }} + {{- with .Values.crds.additionalLabels }} + labels: +{{ toYaml . | indent 4 }} + {{- end }} name: sonarrlanguageprofiles.devopsarr.io spec: group: devopsarr.io @@ -243,3 +257,4 @@ spec: storage: true subresources: status: {} +{{- end }} diff --git a/deploy/crds/mediamanagementconfig.yaml b/charts/sonarr-operator/templates/crds/mediamanagementconfig.yaml similarity index 95% rename from deploy/crds/mediamanagementconfig.yaml rename to charts/sonarr-operator/templates/crds/mediamanagementconfig.yaml index 82e43e4..839175d 100644 --- a/deploy/crds/mediamanagementconfig.yaml +++ b/charts/sonarr-operator/templates/crds/mediamanagementconfig.yaml @@ -1,6 +1,20 @@ +{{- if .Values.crds.install }} apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: + {{- if or .Values.crds.keep .Values.crds.annotations }} + annotations: + {{- if .Values.crds.keep }} + helm.sh/resource-policy: keep + {{- end }} + {{- with .Values.crds.annotations }} +{{ toYaml . | indent 4 }} + {{- end }} + {{- end }} + {{- with .Values.crds.additionalLabels }} + labels: +{{ toYaml . | indent 4 }} + {{- end }} name: sonarrmediamanagementconfigs.devopsarr.io spec: group: devopsarr.io @@ -201,3 +215,4 @@ spec: storage: true subresources: status: {} +{{- end }} diff --git a/deploy/crds/metadata.yaml b/charts/sonarr-operator/templates/crds/metadata.yaml similarity index 94% rename from deploy/crds/metadata.yaml rename to charts/sonarr-operator/templates/crds/metadata.yaml index 7d729c3..cc1ed2f 100644 --- a/deploy/crds/metadata.yaml +++ b/charts/sonarr-operator/templates/crds/metadata.yaml @@ -1,6 +1,20 @@ +{{- if .Values.crds.install }} apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: + {{- if or .Values.crds.keep .Values.crds.annotations }} + annotations: + {{- if .Values.crds.keep }} + helm.sh/resource-policy: keep + {{- end }} + {{- with .Values.crds.annotations }} +{{ toYaml . | indent 4 }} + {{- end }} + {{- end }} + {{- with .Values.crds.additionalLabels }} + labels: +{{ toYaml . | indent 4 }} + {{- end }} name: sonarrmetadatas.devopsarr.io spec: group: devopsarr.io @@ -171,3 +185,4 @@ spec: storage: true subresources: status: {} +{{- end }} diff --git a/deploy/crds/namingconfig.yaml b/charts/sonarr-operator/templates/crds/namingconfig.yaml similarity index 94% rename from deploy/crds/namingconfig.yaml rename to charts/sonarr-operator/templates/crds/namingconfig.yaml index 36cb92c..9677bc6 100644 --- a/deploy/crds/namingconfig.yaml +++ b/charts/sonarr-operator/templates/crds/namingconfig.yaml @@ -1,6 +1,20 @@ +{{- if .Values.crds.install }} apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: + {{- if or .Values.crds.keep .Values.crds.annotations }} + annotations: + {{- if .Values.crds.keep }} + helm.sh/resource-policy: keep + {{- end }} + {{- with .Values.crds.annotations }} +{{ toYaml . | indent 4 }} + {{- end }} + {{- end }} + {{- with .Values.crds.additionalLabels }} + labels: +{{ toYaml . | indent 4 }} + {{- end }} name: sonarrnamingconfigs.devopsarr.io spec: group: devopsarr.io @@ -160,3 +174,4 @@ spec: storage: true subresources: status: {} +{{- end }} diff --git a/deploy/crds/notification.yaml b/charts/sonarr-operator/templates/crds/notification.yaml similarity index 97% rename from deploy/crds/notification.yaml rename to charts/sonarr-operator/templates/crds/notification.yaml index 11140c3..63ad5bb 100644 --- a/deploy/crds/notification.yaml +++ b/charts/sonarr-operator/templates/crds/notification.yaml @@ -1,6 +1,20 @@ +{{- if .Values.crds.install }} apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: + {{- if or .Values.crds.keep .Values.crds.annotations }} + annotations: + {{- if .Values.crds.keep }} + helm.sh/resource-policy: keep + {{- end }} + {{- with .Values.crds.annotations }} +{{ toYaml . | indent 4 }} + {{- end }} + {{- end }} + {{- with .Values.crds.additionalLabels }} + labels: +{{ toYaml . | indent 4 }} + {{- end }} name: sonarrnotifications.devopsarr.io spec: group: devopsarr.io @@ -460,3 +474,4 @@ spec: storage: true subresources: status: {} +{{- end }} diff --git a/deploy/crds/qualitydefinition.yaml b/charts/sonarr-operator/templates/crds/qualitydefinition.yaml similarity index 94% rename from deploy/crds/qualitydefinition.yaml rename to charts/sonarr-operator/templates/crds/qualitydefinition.yaml index 2533607..d6038f2 100644 --- a/deploy/crds/qualitydefinition.yaml +++ b/charts/sonarr-operator/templates/crds/qualitydefinition.yaml @@ -1,6 +1,20 @@ +{{- if .Values.crds.install }} apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: + {{- if or .Values.crds.keep .Values.crds.annotations }} + annotations: + {{- if .Values.crds.keep }} + helm.sh/resource-policy: keep + {{- end }} + {{- with .Values.crds.annotations }} +{{ toYaml . | indent 4 }} + {{- end }} + {{- end }} + {{- with .Values.crds.additionalLabels }} + labels: +{{ toYaml . | indent 4 }} + {{- end }} name: sonarrqualitydefinitions.devopsarr.io spec: group: devopsarr.io @@ -161,3 +175,4 @@ spec: storage: true subresources: status: {} +{{- end }} diff --git a/deploy/crds/qualityprofile.yaml b/charts/sonarr-operator/templates/crds/qualityprofile.yaml similarity index 95% rename from deploy/crds/qualityprofile.yaml rename to charts/sonarr-operator/templates/crds/qualityprofile.yaml index 675bec5..3ea33f7 100644 --- a/deploy/crds/qualityprofile.yaml +++ b/charts/sonarr-operator/templates/crds/qualityprofile.yaml @@ -1,6 +1,20 @@ +{{- if .Values.crds.install }} apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: + {{- if or .Values.crds.keep .Values.crds.annotations }} + annotations: + {{- if .Values.crds.keep }} + helm.sh/resource-policy: keep + {{- end }} + {{- with .Values.crds.annotations }} +{{ toYaml . | indent 4 }} + {{- end }} + {{- end }} + {{- with .Values.crds.additionalLabels }} + labels: +{{ toYaml . | indent 4 }} + {{- end }} name: sonarrqualityprofiles.devopsarr.io spec: group: devopsarr.io @@ -201,3 +215,4 @@ spec: storage: true subresources: status: {} +{{- end }} diff --git a/deploy/crds/rootfolder.yaml b/charts/sonarr-operator/templates/crds/rootfolder.yaml similarity index 92% rename from deploy/crds/rootfolder.yaml rename to charts/sonarr-operator/templates/crds/rootfolder.yaml index 06c1f18..7d59b1f 100644 --- a/deploy/crds/rootfolder.yaml +++ b/charts/sonarr-operator/templates/crds/rootfolder.yaml @@ -1,6 +1,20 @@ +{{- if .Values.crds.install }} apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: + {{- if or .Values.crds.keep .Values.crds.annotations }} + annotations: + {{- if .Values.crds.keep }} + helm.sh/resource-policy: keep + {{- end }} + {{- with .Values.crds.annotations }} +{{ toYaml . | indent 4 }} + {{- end }} + {{- end }} + {{- with .Values.crds.additionalLabels }} + labels: +{{ toYaml . | indent 4 }} + {{- end }} name: sonarrrootfolders.devopsarr.io spec: group: devopsarr.io @@ -120,3 +134,4 @@ spec: storage: true subresources: status: {} +{{- end }} diff --git a/deploy/crds/series.yaml b/charts/sonarr-operator/templates/crds/series.yaml similarity index 95% rename from deploy/crds/series.yaml rename to charts/sonarr-operator/templates/crds/series.yaml index daebcb8..96096db 100644 --- a/deploy/crds/series.yaml +++ b/charts/sonarr-operator/templates/crds/series.yaml @@ -1,6 +1,20 @@ +{{- if .Values.crds.install }} apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: + {{- if or .Values.crds.keep .Values.crds.annotations }} + annotations: + {{- if .Values.crds.keep }} + helm.sh/resource-policy: keep + {{- end }} + {{- with .Values.crds.annotations }} +{{ toYaml . | indent 4 }} + {{- end }} + {{- end }} + {{- with .Values.crds.additionalLabels }} + labels: +{{ toYaml . | indent 4 }} + {{- end }} name: sonarrseries.devopsarr.io spec: group: devopsarr.io @@ -236,3 +250,4 @@ spec: storage: true subresources: status: {} +{{- end }} diff --git a/deploy/crds/sonarr.yaml b/charts/sonarr-operator/templates/crds/sonarr.yaml similarity index 98% rename from deploy/crds/sonarr.yaml rename to charts/sonarr-operator/templates/crds/sonarr.yaml index e876d37..ab595d8 100644 --- a/deploy/crds/sonarr.yaml +++ b/charts/sonarr-operator/templates/crds/sonarr.yaml @@ -1,6 +1,20 @@ +{{- if .Values.crds.install }} apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: + {{- if or .Values.crds.keep .Values.crds.annotations }} + annotations: + {{- if .Values.crds.keep }} + helm.sh/resource-policy: keep + {{- end }} + {{- with .Values.crds.annotations }} +{{ toYaml . | indent 4 }} + {{- end }} + {{- end }} + {{- with .Values.crds.additionalLabels }} + labels: +{{ toYaml . | indent 4 }} + {{- end }} name: sonarrs.devopsarr.io spec: group: devopsarr.io @@ -638,3 +652,4 @@ spec: storage: true subresources: status: {} +{{- end }} diff --git a/deploy/crds/tag.yaml b/charts/sonarr-operator/templates/crds/tag.yaml similarity index 92% rename from deploy/crds/tag.yaml rename to charts/sonarr-operator/templates/crds/tag.yaml index d3a98ae..a601a4f 100644 --- a/deploy/crds/tag.yaml +++ b/charts/sonarr-operator/templates/crds/tag.yaml @@ -1,6 +1,20 @@ +{{- if .Values.crds.install }} apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: + {{- if or .Values.crds.keep .Values.crds.annotations }} + annotations: + {{- if .Values.crds.keep }} + helm.sh/resource-policy: keep + {{- end }} + {{- with .Values.crds.annotations }} +{{ toYaml . | indent 4 }} + {{- end }} + {{- end }} + {{- with .Values.crds.additionalLabels }} + labels: +{{ toYaml . | indent 4 }} + {{- end }} name: sonarrtags.devopsarr.io spec: group: devopsarr.io @@ -111,3 +125,4 @@ spec: storage: true subresources: status: {} +{{- end }} diff --git a/charts/sonarr-operator/templates/deployment.yaml b/charts/sonarr-operator/templates/deployment.yaml new file mode 100644 index 0000000..2fe51ac --- /dev/null +++ b/charts/sonarr-operator/templates/deployment.yaml @@ -0,0 +1,74 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "sonarr-operator.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "sonarr-operator.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + {{- include "sonarr-operator.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "sonarr-operator.selectorLabels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + serviceAccountName: {{ include "sonarr-operator.serviceAccountName" . }} + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.priorityClassName }} + priorityClassName: {{ . }} + {{- end }} + {{- with .Values.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: operator + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + env: + - name: RUST_LOG + value: {{ .Values.logLevel | quote }} + {{- with .Values.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.livenessProbe }} + livenessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.readinessProbe }} + readinessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/charts/sonarr-operator/templates/serviceaccount.yaml b/charts/sonarr-operator/templates/serviceaccount.yaml new file mode 100644 index 0000000..ac235ca --- /dev/null +++ b/charts/sonarr-operator/templates/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "sonarr-operator.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "sonarr-operator.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/charts/sonarr-operator/values.yaml b/charts/sonarr-operator/values.yaml new file mode 100644 index 0000000..3f5ee9f --- /dev/null +++ b/charts/sonarr-operator/values.yaml @@ -0,0 +1,97 @@ +# Default values for sonarr-operator. + +# -- Number of operator replicas. Should remain at 1 (the operator does not yet support leader election). +replicaCount: 1 + +image: + # -- Container image repository. + repository: ghcr.io/devopsarr/k8s-operator-sonarr + # -- Image pull policy. + pullPolicy: IfNotPresent + # -- Image tag. Defaults to the chart's appVersion when empty. + tag: "" + +# -- Image pull secrets for private registries. +imagePullSecrets: [] + +# -- Override the chart name used in resource names. +nameOverride: "" +# -- Fully override the generated fullname used in resource names. +fullnameOverride: "" + +# CRD-related options. +# Disable when managing CRDs out-of-band (e.g. Flux Kustomization, ArgoCD +# ServerSideApply, or multi-tenant clusters where CRDs are installed once). +crds: + # -- Install CRDs via Helm templates when true. + install: true + # -- When true, adds `helm.sh/resource-policy: keep` to each CRD so they + # survive `helm uninstall`. Set to false for ephemeral test clusters. + keep: true + # -- Additional annotations merged into each CRD's `metadata.annotations`. + annotations: {} + # -- Additional labels merged into each CRD's `metadata.labels`. + additionalLabels: {} + +serviceAccount: + # -- Create a ServiceAccount for the operator. + create: true + # -- Annotations to add to the ServiceAccount. + annotations: {} + # -- Name of the ServiceAccount. Auto-generated when empty. + name: "" + +rbac: + # -- Create ClusterRole and ClusterRoleBinding for the operator. + create: true + +# -- Operator log level. Format: `,=` (passed as RUST_LOG). +logLevel: "info,sonarr_operator=debug" + +# -- Extra environment variables for the operator container. +extraEnv: [] +# - name: FOO +# value: bar + +# -- Pod-level security context. +podSecurityContext: + fsGroup: 65534 + +# -- Container-level security context. +securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsUser: 65534 + capabilities: + drop: + - ALL + +# -- Resource requests and limits for the operator container. +resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 200m + memory: 256Mi + +# -- Pod annotations. +podAnnotations: {} +# -- Pod labels. +podLabels: {} + +# -- Node selector for the operator Pod. +nodeSelector: {} +# -- Tolerations for the operator Pod. +tolerations: [] +# -- Affinity rules for the operator Pod. +affinity: {} + +# -- Priority class name for the operator Pod. +priorityClassName: "" + +# -- Liveness probe for the operator container. +livenessProbe: {} +# -- Readiness probe for the operator container. +readinessProbe: {} diff --git a/deploy/examples/sonarr-minimal.yaml b/deploy/examples/sonarr-minimal.yaml new file mode 100644 index 0000000..2175d93 --- /dev/null +++ b/deploy/examples/sonarr-minimal.yaml @@ -0,0 +1,40 @@ +# Minimal Sonarr instance. +# +# This example creates a single Sonarr deployment with default settings. +# The operator will reconcile this resource into a Deployment, Service, +# PVC, and (if configured) Ingress/HTTPRoute. +# +# Prerequisites: +# 1. The sonarr-operator chart must be installed in the cluster. +# 2. Either omit `apiKeySecretRef` (the operator will auto-generate a Secret) +# OR pre-create a Secret with your chosen key: +# +# kubectl create secret generic sonarr-api-key \ +# --from-literal=api-key="$(openssl rand -hex 16)" \ +# -n default +# +# Apply: +# kubectl apply -f sonarr-minimal.yaml +# kubectl wait sonarr/sonarr --for=condition=Ready --timeout=5m +--- +apiVersion: devopsarr.io/v1alpha1 +kind: Sonarr +metadata: + name: sonarr + namespace: default +spec: + # Reference to a Secret containing the API key. Omit to have the + # operator auto-generate one. + apiKeySecretRef: + name: sonarr-api-key + key: api-key + + # Expose Sonarr via a ClusterIP Service on the default port (8989). + service: + serviceType: ClusterIP + + # Application configuration written into config.xml on first start. + config: + # Use Forms authentication in production; None is shown here for + # the simplest possible quickstart. + authenticationMethod: None diff --git a/release-please-config.json b/release-please-config.json index aae3a3e..6cfd405 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -1,12 +1,34 @@ { "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "separate-pull-requests": false, + "plugins": [ + { + "type": "linked-versions", + "groupName": "sonarr-operator", + "components": [ + "sonarr-operator", + "sonarr-operator-chart" + ] + } + ], "packages": { ".": { "release-type": "rust", - "initial-version": "0.1.0", + "component": "sonarr-operator", + "package-name": "sonarr-operator", + "include-component-in-tag": false, "extra-files": [ "deploy/deployment.yaml" ] + }, + "charts/sonarr-operator": { + "release-type": "helm", + "component": "sonarr-operator-chart", + "package-name": "sonarr-operator-chart", + "include-component-in-tag": true, + "extra-files": [ + "Chart.yaml" + ] } } } diff --git a/src/bin/crdgen.rs b/src/bin/crdgen.rs index 008bd54..c831f12 100644 --- a/src/bin/crdgen.rs +++ b/src/bin/crdgen.rs @@ -1,19 +1,17 @@ //! CRD Generator Binary //! -//! This binary generates the Kubernetes Custom Resource Definitions (CRDs) for the Sonarr operator. +//! Generates the Kubernetes Custom Resource Definitions for the Sonarr operator. //! //! Usage: -//! cargo run --bin crdgen # Output all CRDs to stdout (combined) -//! cargo run --bin crdgen -- --split # Write individual CRD files to directory -//! cargo run --bin crdgen -- --single # Output a single CRD to stdout +//! cargo run --bin crdgen # All CRDs to stdout (combined) +//! cargo run --bin crdgen -- --split # Plain CRD files (one per CRD) +//! cargo run --bin crdgen -- --split --helm # Helm-templated CRDs (gated + keep) +//! cargo run --bin crdgen -- --single # Single CRD to stdout //! //! Examples: //! cargo run --bin crdgen > crds.yaml -//! cargo run --bin crdgen -- --split deploy/crds +//! cargo run --bin crdgen -- --split charts/sonarr-operator/templates/crds --helm //! cargo run --bin crdgen -- --single SonarrTag > tag-crd.yaml -//! -//! This outputs the CRDs as YAML which can be applied to a Kubernetes cluster: -//! kubectl apply -f deploy/crds/ use kube::CustomResourceExt; use std::fs; @@ -69,12 +67,72 @@ fn get_all_crds() -> Vec { ) } +/// Inject Helm templating into a serialised CRD YAML document so it can be +/// rendered by the chart. The wrapper: +/// - guards the whole document on `.Values.crds.install` +/// - conditionally emits `helm.sh/resource-policy: keep` based on `.Values.crds.keep` +/// - merges user-supplied `.Values.crds.annotations` and `.Values.crds.additionalLabels` +/// +/// The annotation injection is purely string-level (no extra deps): CRDs always +/// serialise with a top-level `metadata:` block produced by `serde_yaml`, so we +/// anchor on that. +fn wrap_for_helm(yaml: &str) -> String { + const ANCHOR: &str = "metadata:\n"; + + // Helm-templated metadata stanza to be injected after `metadata:`. It: + // - emits an `annotations:` block only when `.Values.crds.keep` or + // `.Values.crds.annotations` are present (using `or`) + // - merges user-provided annotations and labels via `toYaml` so callers + // can customize per-chart extra metadata + const METADATA_INJECTION: &str = r#" {{- if or .Values.crds.keep .Values.crds.annotations }} + annotations: + {{- if .Values.crds.keep }} + helm.sh/resource-policy: keep + {{- end }} + {{- with .Values.crds.annotations }} +{{ toYaml . | indent 4 }} + {{- end }} + {{- end }} + {{- with .Values.crds.additionalLabels }} + labels: +{{ toYaml . | indent 4 }} + {{- end }} +"#; + + let injected = match yaml.find(ANCHOR) { + Some(idx) => { + let insert_at = idx + ANCHOR.len(); + let mut s = String::with_capacity(yaml.len() + METADATA_INJECTION.len()); + s.push_str(&yaml[..insert_at]); + s.push_str(METADATA_INJECTION); + s.push_str(&yaml[insert_at..]); + s + } + None => { + eprintln!( + "warning: could not find `metadata:` anchor; emitting CRD without helm metadata injection" + ); + yaml.to_string() + } + }; + + let mut out = String::with_capacity(injected.len() + 96); + out.push_str("{{- if .Values.crds.install }}\n"); + out.push_str(&injected); + if !injected.ends_with('\n') { + out.push('\n'); + } + out.push_str("{{- end }}\n"); + out +} + fn main() { let args: Vec = std::env::args().collect(); // Check for --split flag (write individual files) if args.len() > 2 && args[1] == "--split" { let output_dir = &args[2]; + let helm_mode = args.iter().any(|a| a == "--helm"); let path = Path::new(output_dir); // Create directory if it doesn't exist @@ -87,15 +145,24 @@ fn main() { for crd_info in crds { let file_path = path.join(crd_info.filename); let yaml = serde_yaml::to_string(&crd_info.crd).unwrap(); + let contents = if helm_mode { + wrap_for_helm(&yaml) + } else { + yaml + }; - if let Err(e) = fs::write(&file_path, yaml) { + if let Err(e) = fs::write(&file_path, contents) { eprintln!("Failed to write {}: {}", file_path.display(), e); std::process::exit(1); } eprintln!("Generated: {}", file_path.display()); } - eprintln!("All CRDs generated in {}/", output_dir); + eprintln!( + "All CRDs generated in {}/{}", + output_dir, + if helm_mode { " (helm mode)" } else { "" } + ); return; } From 3b22a6bd45112e00c0a416ab3cfc2487188e5d86 Mon Sep 17 00:00:00 2001 From: Fuochi Date: Sun, 31 May 2026 09:37:13 +0200 Subject: [PATCH 21/22] refactor: simplify error messages in integration tests and improve code readability Co-authored-by: Copilot --- .../scenarios/cross_resource_dependencies.rs | 30 +++++++++---------- tests/e2e/scenarios/tag_lifecycle.rs | 4 +-- tests/integration/common.rs | 25 ++++++++-------- tests/integration/quality_definition_crd.rs | 5 +--- tests/integration/root_folder_crd.rs | 4 +-- tests/integration/tag_crd.rs | 2 +- 6 files changed, 33 insertions(+), 37 deletions(-) diff --git a/tests/e2e/scenarios/cross_resource_dependencies.rs b/tests/e2e/scenarios/cross_resource_dependencies.rs index 423406d..2957237 100644 --- a/tests/e2e/scenarios/cross_resource_dependencies.rs +++ b/tests/e2e/scenarios/cross_resource_dependencies.rs @@ -350,21 +350,21 @@ async fn test_invalid_tag_reference_handling() { let api: Api = Api::namespaced(ctx.client.clone(), E2E_NAMESPACE); let result = api.get(&autotag_name).await; - if let Ok(autotag) = result { - if let Some(status) = &autotag.status { - let has_error = status - .conditions - .iter() - .any(|c| c.type_ == "Ready" && c.status == "False"); - - if has_error { - tracing::info!("✓ AutoTag correctly shows error for invalid tag reference"); - } else { - // Some Sonarr versions accept invalid tag IDs - tracing::warn!( - "AutoTag was created despite invalid tag reference - Sonarr may accept any tag ID" - ); - } + if let Ok(autotag) = result + && let Some(status) = &autotag.status + { + let has_error = status + .conditions + .iter() + .any(|c| c.type_ == "Ready" && c.status == "False"); + + if has_error { + tracing::info!("✓ AutoTag correctly shows error for invalid tag reference"); + } else { + // Some Sonarr versions accept invalid tag IDs + tracing::warn!( + "AutoTag was created despite invalid tag reference - Sonarr may accept any tag ID" + ); } } diff --git a/tests/e2e/scenarios/tag_lifecycle.rs b/tests/e2e/scenarios/tag_lifecycle.rs index 927f87f..82bafd4 100644 --- a/tests/e2e/scenarios/tag_lifecycle.rs +++ b/tests/e2e/scenarios/tag_lifecycle.rs @@ -199,7 +199,7 @@ async fn test_multiple_tags() { for tag_name in &tag_names { wait_for_ready::(&ctx.client, E2E_NAMESPACE, tag_name, E2E_TIMEOUT) .await - .expect(&format!("Tag {} never became ready", tag_name)); + .expect("Tag never became ready"); } // Verify all exist in Sonarr @@ -209,7 +209,7 @@ async fn test_multiple_tags() { .find_tag_by_label(tag_name) .await .expect("Failed to query Sonarr") - .expect(&format!("Tag {} not found in Sonarr", tag_name)); + .expect("Tag not found in Sonarr"); tracing::info!( "✓ Tag {} created in Sonarr with ID {}", diff --git a/tests/integration/common.rs b/tests/integration/common.rs index 4fc651c..405eecb 100644 --- a/tests/integration/common.rs +++ b/tests/integration/common.rs @@ -1,5 +1,7 @@ //! Common utilities for integration tests +#![allow(dead_code)] + use k8s_openapi::apiextensions_apiserver::pkg::apis::apiextensions::v1::CustomResourceDefinition; use kube::{ Client, Resource, @@ -89,12 +91,12 @@ pub async fn is_crd_established(client: &Client, crd_name: &str) -> bool { match crds.get(crd_name).await { Ok(crd) => { - if let Some(status) = crd.status { - if let Some(conditions) = status.conditions { - return conditions - .iter() - .any(|c| c.type_ == "Established" && c.status == "True"); - } + if let Some(status) = crd.status + && let Some(conditions) = status.conditions + { + return conditions + .iter() + .any(|c| c.type_ == "Established" && c.status == "True"); } false } @@ -181,13 +183,10 @@ where timeout(DEFAULT_TIMEOUT, async { loop { - match api.get(name).await { - Ok(resource) => { - if condition_fn(&resource) { - return Ok(resource); - } - } - Err(_) => {} + if let Ok(resource) = api.get(name).await + && condition_fn(&resource) + { + return Ok(resource); } tokio::time::sleep(Duration::from_millis(500)).await; } diff --git a/tests/integration/quality_definition_crd.rs b/tests/integration/quality_definition_crd.rs index dc99c1e..6db1e42 100644 --- a/tests/integration/quality_definition_crd.rs +++ b/tests/integration/quality_definition_crd.rs @@ -253,10 +253,7 @@ async fn test_configure_different_quality_levels() { api.patch(&name, &patch_params, &Patch::Apply(&qd)) .await - .expect(&format!( - "Failed to create quality definition for {}", - quality_slug - )); + .expect("Failed to create quality definition"); } // Verify all were created diff --git a/tests/integration/root_folder_crd.rs b/tests/integration/root_folder_crd.rs index 520c90c..3e06f42 100644 --- a/tests/integration/root_folder_crd.rs +++ b/tests/integration/root_folder_crd.rs @@ -84,7 +84,7 @@ async fn test_create_multiple_root_folders() { let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); let patch_params = PatchParams::apply("sonarr-operator-test").force(); - let paths = vec!["/tv/shows", "/tv/anime", "/tv/documentaries"]; + let paths = ["/tv/shows", "/tv/anime", "/tv/documentaries"]; let mut names = Vec::new(); for (i, path) in paths.iter().enumerate() { @@ -110,7 +110,7 @@ async fn test_create_multiple_root_folders() { api.patch(&name, &patch_params, &Patch::Apply(&root_folder)) .await - .expect(&format!("Failed to create root folder for path {}", path)); + .expect("Failed to create root folder"); } // List with label selector diff --git a/tests/integration/tag_crd.rs b/tests/integration/tag_crd.rs index dca5f48..58d9fb7 100644 --- a/tests/integration/tag_crd.rs +++ b/tests/integration/tag_crd.rs @@ -219,7 +219,7 @@ async fn test_create_multiple_tags() { api.patch(&name, &patch_params, &Patch::Apply(&tag)) .await - .expect(&format!("Failed to create tag {}", tag_label)); + .expect("Failed to create tag"); } // List with label selector From c0202fee32486cdd781443a7f3f1851194d5a2ba Mon Sep 17 00:00:00 2001 From: Fuochi Date: Thu, 30 Jul 2026 19:57:17 +0200 Subject: [PATCH 22/22] ci: fix integration test matrix and shellcheck warnings - bump K8s matrix from unavailable v1.28/v1.29 to v1.32/v1.33/latest - add github-token to setup-k3d-k3s (avoid API rate limits) - quote $GITHUB_OUTPUT to satisfy shellcheck SC2086 - quote basename argument in chart packaging step - add scripts/verify-release.sh for post-release verification --- .github/workflows/cd.yml | 2 +- .github/workflows/ci.yml | 5 +-- scripts/verify-release.sh | 68 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 3 deletions(-) create mode 100755 scripts/verify-release.sh diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 625dc9c..90b8d40 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -174,7 +174,7 @@ jobs: helm package charts/sonarr-operator -d dist/ pkg=$(ls dist/sonarr-operator-*.tgz) echo "path=${pkg}" >> "$GITHUB_OUTPUT" - echo "name=$(basename ${pkg})" >> "$GITHUB_OUTPUT" + echo "name=$(basename "${pkg}")" >> "$GITHUB_OUTPUT" - name: Push chart to GHCR id: push diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c995de..117c7c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -162,7 +162,7 @@ jobs: - name: Get short SHA id: short-sha - run: echo "sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT + run: echo "sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT" - name: Log in to GitHub Container Registry uses: docker/login-action@v3 @@ -203,7 +203,7 @@ jobs: strategy: fail-fast: false matrix: - k8s: [ v1.28, v1.29, latest ] + k8s: [ v1.32, v1.33, latest ] steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable @@ -222,6 +222,7 @@ jobs: with: version: ${{ matrix.k8s }} k3d-name: sonarr-operator-test + github-token: ${{ secrets.GITHUB_TOKEN }} k3d-args: '--no-lb --no-rollback --k3s-arg --disable=traefik,servicelb,metrics-server@server:*' diff --git a/scripts/verify-release.sh b/scripts/verify-release.sh new file mode 100755 index 0000000..f8db23c --- /dev/null +++ b/scripts/verify-release.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Verify a published release of the sonarr-operator: +# - operator image exists on GHCR for the given version +# - chart exists on GHCR +# - cosign signatures verify (keyless, GitHub OIDC) +# - chart renders cleanly with helm template +# +# Usage: +# scripts/verify-release.sh v0.1.0 +# +# Requirements: docker, cosign, helm, jq (optional) +# For private packages, run `docker login ghcr.io` and +# `helm registry login ghcr.io` beforehand. + +set -euo pipefail + +VERSION="${1:?usage: $0 (e.g. v0.1.0)}" +VERSION_NOPREFIX="${VERSION#v}" + +OWNER="${OWNER:-devopsarr}" +REPO="${REPO:-k8s-operator-sonarr}" +IMAGE="ghcr.io/${OWNER}/${REPO}:${VERSION}" +CHART_REF="ghcr.io/${OWNER}/charts/sonarr-operator" +CHART="oci://${CHART_REF}" +EXPECTED_IDENTITY_REGEX="^https://github.com/${OWNER}/${REPO}/" +OIDC_ISSUER="https://token.actions.githubusercontent.com" + +step() { printf "\n==> %s\n" "$*"; } +ok() { printf " ✓ %s\n" "$*"; } +fail() { printf " ✗ %s\n" "$*" >&2; exit 1; } + +require() { + command -v "$1" >/dev/null 2>&1 || fail "missing required tool: $1" +} + +require docker +require cosign +require helm + +step "Pulling operator image: ${IMAGE}" +docker pull "${IMAGE}" >/dev/null && ok "image pulled" + +step "Verifying image signature (keyless cosign)" +cosign verify "${IMAGE}" \ + --certificate-identity-regexp "${EXPECTED_IDENTITY_REGEX}" \ + --certificate-oidc-issuer "${OIDC_ISSUER}" >/dev/null && ok "image signature verified" + +step "Pulling chart: ${CHART} --version ${VERSION_NOPREFIX}" +WORKDIR="$(mktemp -d)" +trap 'rm -rf "${WORKDIR}"' EXIT +helm pull "${CHART}" --version "${VERSION_NOPREFIX}" --destination "${WORKDIR}" >/dev/null && ok "chart pulled" + +CHART_TGZ="$(ls "${WORKDIR}"/sonarr-operator-*.tgz)" +ok "chart artifact: $(basename "${CHART_TGZ}")" + +step "Verifying chart signature (keyless cosign)" +cosign verify "${CHART_REF}:${VERSION_NOPREFIX}" \ + --certificate-identity-regexp "${EXPECTED_IDENTITY_REGEX}" \ + --certificate-oidc-issuer "${OIDC_ISSUER}" >/dev/null && ok "chart signature verified" + +step "helm template (defaults)" +helm template verify "${CHART_TGZ}" --namespace sonarr-operator-system >/dev/null && ok "chart renders" + +step "helm template (CRDs gated out)" +helm template verify "${CHART_TGZ}" --namespace sonarr-operator-system \ + --set crds.install=false >/dev/null && ok "chart renders without CRDs" + +step "All checks passed for ${VERSION}"