diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
new file mode 100644
index 0000000..45ddee6
--- /dev/null
+++ b/.github/workflows/publish.yml
@@ -0,0 +1,33 @@
+name: Publish Docker Image
+
+on:
+ # Trigger on push
+ push:
+ # Trigger on all pull requests
+ pull_request:
+ # Trigger when a release is published
+ release:
+ types: ['published']
+
+jobs:
+ publish-container-image:
+ permissions:
+ id-token: write
+ contents: read
+ packages: write
+ attestations: write
+ uses: datum-cloud/actions/.github/workflows/publish-docker.yaml@v1.5.1
+ with:
+ image-name: dns-operator
+ secrets: inherit
+
+ publish-kustomize-bundles:
+ permissions:
+ id-token: write
+ contents: read
+ packages: write
+ uses: datum-cloud/actions/.github/workflows/publish-kustomize-bundle.yaml@v1.5.1
+ with:
+ bundle-name: ghcr.io/datum-cloud/dns-operator-kustomize
+ bundle-path: config
+ secrets: inherit
diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml
deleted file mode 100644
index 68fd1ed..0000000
--- a/.github/workflows/test-e2e.yml
+++ /dev/null
@@ -1,32 +0,0 @@
-name: E2E Tests
-
-on:
- push:
- pull_request:
-
-jobs:
- test-e2e:
- name: Run on Ubuntu
- runs-on: ubuntu-latest
- steps:
- - name: Clone the code
- uses: actions/checkout@v4
-
- - name: Setup Go
- uses: actions/setup-go@v5
- with:
- go-version-file: go.mod
-
- - name: Install the latest version of kind
- run: |
- curl -Lo ./kind https://kind.sigs.k8s.io/dl/latest/kind-linux-amd64
- chmod +x ./kind
- sudo mv ./kind /usr/local/bin/kind
-
- - name: Verify kind installation
- run: kind version
-
- - name: Running Test e2e
- run: |
- go mod tidy
- make test-e2e
diff --git a/.gitignore b/.gitignore
index 5c47380..401eae5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,7 +10,10 @@
bin/
vendor/
+dist/
+dev/
.go-version
+config/**/charts
# Test binary, built with `go test -c`
*.test
diff --git a/.golangci.yml b/.golangci.yml
index e5b21b0..af5d15c 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -26,6 +26,8 @@ linters:
rules:
- name: comment-spacings
- name: import-shadowing
+ gocyclo:
+ min-complexity: 70
exclusions:
generated: lax
rules:
diff --git a/LICENSE b/LICENSE
deleted file mode 100644
index 0ad25db..0000000
--- a/LICENSE
+++ /dev/null
@@ -1,661 +0,0 @@
- GNU AFFERO GENERAL PUBLIC LICENSE
- Version 3, 19 November 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 Affero General Public License is a free, copyleft license for
-software and other kinds of works, specifically designed to ensure
-cooperation with the community in the case of network server software.
-
- The licenses for most software and other practical works are designed
-to take away your freedom to share and change the works. By contrast,
-our General Public Licenses are 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.
-
- 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.
-
- Developers that use our General Public Licenses protect your rights
-with two steps: (1) assert copyright on the software, and (2) offer
-you this License which gives you legal permission to copy, distribute
-and/or modify the software.
-
- A secondary benefit of defending all users' freedom is that
-improvements made in alternate versions of the program, if they
-receive widespread use, become available for other developers to
-incorporate. Many developers of free software are heartened and
-encouraged by the resulting cooperation. However, in the case of
-software used on network servers, this result may fail to come about.
-The GNU General Public License permits making a modified version and
-letting the public access it on a server without ever releasing its
-source code to the public.
-
- The GNU Affero General Public License is designed specifically to
-ensure that, in such cases, the modified source code becomes available
-to the community. It requires the operator of a network server to
-provide the source code of the modified version running there to the
-users of that server. Therefore, public use of a modified version, on
-a publicly accessible server, gives the public access to the source
-code of the modified version.
-
- An older license, called the Affero General Public License and
-published by Affero, was designed to accomplish similar goals. This is
-a different license, not a version of the Affero GPL, but Affero has
-released a new version of the Affero GPL which permits relicensing under
-this license.
-
- 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 Affero 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. Remote Network Interaction; Use with the GNU General Public License.
-
- Notwithstanding any other provision of this License, if you modify the
-Program, your modified version must prominently offer all users
-interacting with it remotely through a computer network (if your version
-supports such interaction) an opportunity to receive the Corresponding
-Source of your version by providing access to the Corresponding Source
-from a network server at no charge, through some standard or customary
-means of facilitating copying of software. This Corresponding Source
-shall include the Corresponding Source for any work covered by version 3
-of the GNU General Public License that is incorporated pursuant to the
-following paragraph.
-
- 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 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 work with which it is combined will remain governed by version
-3 of the GNU General Public License.
-
- 14. Revised Versions of this License.
-
- The Free Software Foundation may publish revised and/or new versions of
-the GNU Affero 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 Affero 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 Affero 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 Affero 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 Affero 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 Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see .
-
-Also add information on how to contact you by electronic and paper mail.
-
- If your software can interact with users remotely through a computer
-network, you should also make sure that it provides a way for users to
-get its source. For example, if your program is a web application, its
-interface could display a "Source" link that leads users to an archive
-of the code. There are many ways you could offer source, and different
-solutions will be better for different programs; see section 13 for the
-specific requirements.
-
- 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 AGPL, see
-.
diff --git a/Makefile b/Makefile
index ac554e7..2df25c9 100644
--- a/Makefile
+++ b/Makefile
@@ -1,5 +1,5 @@
# Image URL to use all building/pushing image targets
-IMG ?= controller:latest
+IMG ?= ghcr.io/datum-cloud/dns-operator:latest
# Get the currently used golang install path (in GOPATH/bin, unless GOBIN is set)
ifeq (,$(shell go env GOBIN))
@@ -46,8 +46,9 @@ manifests: controller-gen ## Generate WebhookConfiguration, ClusterRole and Cust
$(CONTROLLER_GEN) rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases
.PHONY: generate
-generate: controller-gen ## Generate code containing DeepCopy, DeepCopyInto, and DeepCopyObject method implementations.
+generate: controller-gen defaulter-gen ## Generate code containing DeepCopy, DeepCopyInto, and DeepCopyObject method implementations.
$(CONTROLLER_GEN) object:headerFile="hack/boilerplate.go.txt" paths="./..."
+ $(DEFAULTER_GEN) ./internal/config --output-file=zz_generated.defaults.go
.PHONY: fmt
fmt: ## Run go fmt against code.
@@ -81,14 +82,6 @@ setup-test-e2e: ## Set up a Kind cluster for e2e tests if it does not exist
$(KIND) create cluster --name $(KIND_CLUSTER) ;; \
esac
-.PHONY: test-e2e
-test-e2e: setup-test-e2e manifests generate fmt vet ## Run the e2e tests. Expected an isolated environment using Kind.
- KIND=$(KIND) KIND_CLUSTER=$(KIND_CLUSTER) go test -tags=e2e ./test/e2e/ -v -ginkgo.v
- $(MAKE) cleanup-test-e2e
-
-.PHONY: cleanup-test-e2e
-cleanup-test-e2e: ## Tear down the Kind cluster used for e2e tests
- @$(KIND) delete cluster --name $(KIND_CLUSTER)
.PHONY: lint
lint: golangci-lint ## Run golangci-lint linter
@@ -171,6 +164,178 @@ deploy: manifests kustomize ## Deploy controller to the K8s cluster specified in
undeploy: kustomize ## Undeploy controller from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion.
$(KUSTOMIZE) build config/default | $(KUBECTL) delete --ignore-not-found=$(ignore-not-found) -f -
+.PHONY: cert-manager
+cert-manager: kustomize cmctl ## Install cert-manager into the cluster selected by CONTEXT and/or KUBECONFIG
+ @FLAGS="$(if $(CONTEXT),--context $(CONTEXT),) $(if $(KUBECONFIG),--kubeconfig $(KUBECONFIG),)"; \
+ echo "[cert-manager] applying to $$FLAGS"; \
+ $(KUSTOMIZE) build --enable-helm config/tools/cert-manager \
+ | $(KUBECTL) $$FLAGS apply --server-side=true --force-conflicts -f -; \
+ $(CMCTL) $$FLAGS check api --wait=5m
+
+##@ Kind bootstrap
+
+# Cluster names
+DOWNSTREAM_CLUSTER_NAME ?= dns-downstream
+UPSTREAM_CLUSTER_NAME ?= dns-upstream
+
+# Image to load into kind clusters
+KIND_IMAGE ?= $(IMG)
+
+# Network Services Operator integration
+NSO_REPO ?= https://github.com/datum-cloud/network-services-operator.git
+NSO_DIR ?= dev/network-services-operator
+NSO_IMG ?= ghcr.io/datum-cloud/network-services-operator:latest
+NSO_NAMESPACE ?= network-services-operator-system
+NSO_DEPLOY ?= false
+
+# Host to rewrite kubeconfig servers for in-cluster access (Docker Desktop/macOS default)
+KIND_KUBECONFIG_HOST ?= host.docker.internal
+
+.PHONY: kind-create
+kind-create: ## Create a kind cluster with name CLUSTER
+ @test -n "$(CLUSTER)" || { echo "CLUSTER is required, e.g. make kind-create CLUSTER=$(DOWNSTREAM_CLUSTER_NAME)"; exit 1; }
+ @case "$$($(KIND) get clusters)" in \
+ *"$(CLUSTER)"*) echo "Kind cluster '$(CLUSTER)' already exists. Skipping." ;; \
+ *) echo "Creating Kind cluster '$(CLUSTER)'..."; $(KIND) create cluster --name $(CLUSTER) ;; \
+ esac
+
+.PHONY: kind-delete
+kind-delete: ## Delete a kind cluster with name CLUSTER
+ @test -n "$(CLUSTER)" || { echo "CLUSTER is required"; exit 1; }
+ $(KIND) delete cluster --name $(CLUSTER)
+
+.PHONY: kind-load-image
+kind-load-image: ## Build and load manager image into kind cluster
+ @test -n "$(CLUSTER)" || { echo "CLUSTER is required"; exit 1; }
+ $(MAKE) docker-build IMG=$(KIND_IMAGE)
+ $(KIND) load docker-image $(KIND_IMAGE) --name $(CLUSTER)
+
+.PHONY: kustomize-apply
+kustomize-apply: kustomize ## Apply a kustomize directory to a specific kubectl context
+ @test -n "$(KUSTOMIZE_DIR)" || { echo "KUSTOMIZE_DIR is required"; exit 1; }
+ @test -n "$(CONTEXT)" || { echo "CONTEXT is required"; exit 1; }
+ $(KUSTOMIZE) build --load-restrictor LoadRestrictionsNone $(KUSTOMIZE_DIR) | $(KUBECTL) --context $(CONTEXT) apply -f -
+
+.PHONY: kustomize-apply-ssa
+kustomize-apply-ssa: kustomize ## Apply a kustomize directory with server-side apply and force conflicts
+ @test -n "$(KUSTOMIZE_DIR)" || { echo "KUSTOMIZE_DIR is required"; exit 1; }
+ @test -n "$(CONTEXT)" || { echo "CONTEXT is required"; exit 1; }
+ $(KUSTOMIZE) build --load-restrictor LoadRestrictionsNone $(KUSTOMIZE_DIR) | $(KUBECTL) --context $(CONTEXT) apply --server-side=true --force-conflicts -f -
+
+.PHONY: export-kind-kubeconfig
+export-kind-kubeconfig: ## Export kind kubeconfig for CLUSTER to OUT and rewrite server host
+ @test -n "$(CLUSTER)" || { echo "CLUSTER is required"; exit 1; }
+ @test -n "$(OUT)" || { echo "OUT is required"; exit 1; }
+ @mkdir -p $(dir $(OUT))
+ $(KIND) get kubeconfig --name $(CLUSTER) > $(OUT).raw
+ # Replace server host with $(KIND_KUBECONFIG_HOST) to be reachable from other Docker containers
+ sed -E "s#(server:[[:space:]]*https://)[^:]+(:[0-9]+)#\1$(KIND_KUBECONFIG_HOST)\2#g" $(OUT).raw > $(OUT)
+ @if [ "$(KIND_KUBECONFIG_INSECURE)" = "true" ]; then \
+ echo "Rewriting kubeconfig to skip TLS verify (dev only)"; \
+ sed -E "s#^([[:space:]]*)certificate-authority-data:.*#\1insecure-skip-tls-verify: true#" $(OUT) > $(OUT).tmp; \
+ mv $(OUT).tmp $(OUT); \
+ fi
+ @rm -f $(OUT).raw
+
+.PHONY: export-kind-kubeconfig-raw
+export-kind-kubeconfig-raw: ## Export kind kubeconfig for CLUSTER to OUT without rewriting
+ @test -n "$(CLUSTER)" || { echo "CLUSTER is required"; exit 1; }
+ @test -n "$(OUT)" || { echo "OUT is required"; exit 1; }
+ @mkdir -p $(dir $(OUT))
+ $(KIND) get kubeconfig --name $(CLUSTER) > $(OUT)
+
+.PHONY: secret-from-file
+secret-from-file: ## Create or update a secret from a file in NAMESPACE on CONTEXT
+ @test -n "$(NAMESPACE)" || { echo "NAMESPACE is required"; exit 1; }
+ @test -n "$(NAME)" || { echo "NAME is required"; exit 1; }
+ @test -n "$(KEY)" || { echo "KEY is required"; exit 1; }
+ @test -n "$(FILE)" || { echo "FILE is required"; exit 1; }
+ @test -n "$(CONTEXT)" || { echo "CONTEXT is required"; exit 1; }
+ $(KUBECTL) --context $(CONTEXT) -n $(NAMESPACE) create secret generic $(NAME) --from-file=$(KEY)=$(FILE) --dry-run=client -o yaml | $(KUBECTL) --context $(CONTEXT) apply -f -
+
+.PHONY: bootstrap-downstream
+bootstrap-downstream: ## Create kind downstream and deploy agent with embedded PowerDNS
+ CLUSTER=$(DOWNSTREAM_CLUSTER_NAME) $(MAKE) kind-create
+ CLUSTER=$(DOWNSTREAM_CLUSTER_NAME) $(MAKE) kind-load-image
+ CONTEXT=kind-$(DOWNSTREAM_CLUSTER_NAME) KUSTOMIZE_DIR=config/overlays/agent-powerdns $(MAKE) kustomize-apply
+ # Export external kubeconfig for downstream cluster (reachable from host/other containers)
+ CLUSTER=$(DOWNSTREAM_CLUSTER_NAME) OUT=dev/kind.downstream.kubeconfig $(MAKE) export-kind-kubeconfig-raw
+
+.PHONY: bootstrap-upstream
+bootstrap-upstream: ## Create kind upstream and deploy replicator pointing to downstream
+ @test -n "$(DOWNSTREAM_KUBECONFIG)" || { echo "DOWNSTREAM_KUBECONFIG is required. Generate with: make export-downstream-kubeconfig OUT=dev/downstream.kubeconfig"; exit 1; }
+ CLUSTER=$(UPSTREAM_CLUSTER_NAME) $(MAKE) kind-create
+ CLUSTER=$(UPSTREAM_CLUSTER_NAME) $(MAKE) kind-load-image
+ CONTEXT=kind-$(UPSTREAM_CLUSTER_NAME) $(MAKE) cert-manager
+ # Install networking services operator CRDs (Domain, etc.) into upstream
+ CONTEXT=kind-$(UPSTREAM_CLUSTER_NAME) $(MAKE) install-networking-crds
+ # Export external kubeconfig for upstream cluster (used to deploy NSO)
+ CLUSTER=$(UPSTREAM_CLUSTER_NAME) OUT=dev/kind.upstream.kubeconfig $(MAKE) export-kind-kubeconfig-raw
+ # Optionally build and deploy Network Services Operator into upstream
+ @if [ "$(NSO_DEPLOY)" = "true" ]; then \
+ echo "[bootstrap-upstream] Installing Kubernetes Gateway API CRDs ($(GATEWAY_API_VERSION))"; \
+ $(MAKE) install-gateway-api-crds CONTEXT=kind-$(UPSTREAM_CLUSTER_NAME) ; \
+ $(MAKE) nso-deploy-upstream KUBECONFIG=$(abspath dev/kind.upstream.kubeconfig) ; \
+ echo "[bootstrap-upstream] Re-applying NSO overlay (config + RBAC) with server-side apply to override defaults"; \
+ CONTEXT=kind-$(UPSTREAM_CLUSTER_NAME) KUSTOMIZE_DIR=config/overlays/nso $(MAKE) kustomize-apply-ssa ; \
+ echo "[bootstrap-upstream] Restarting NSO to pick up new ConfigMap"; \
+ kubectl --context kind-$(UPSTREAM_CLUSTER_NAME) -n network-services-operator-system rollout restart deploy/network-services-operator-controller-manager || true ; \
+ else \
+ echo "[bootstrap-upstream] Skipping NSO deployment (NSO_DEPLOY=false). Only CRDs installed."; \
+ fi
+ # Ensure namespace exists for secret
+ $(KUBECTL) --context kind-$(UPSTREAM_CLUSTER_NAME) create namespace dns-replicator-system --dry-run=client -o yaml | $(KUBECTL) --context kind-$(UPSTREAM_CLUSTER_NAME) apply -f -
+ # Create secret with downstream kubeconfig in upstream cluster
+ CONTEXT=kind-$(UPSTREAM_CLUSTER_NAME) NAMESPACE=dns-replicator-system NAME=downstream-kubeconfig KEY=kubeconfig FILE=$(DOWNSTREAM_KUBECONFIG) $(MAKE) secret-from-file
+ # Deploy replicator overlay
+ CONTEXT=kind-$(UPSTREAM_CLUSTER_NAME) KUSTOMIZE_DIR=config/overlays/replicator $(MAKE) kustomize-apply
+
+.PHONY: export-downstream-kubeconfig
+export-downstream-kubeconfig: ## Export downstream kubeconfig rewritten for in-cluster usage by upstream
+ @test -n "$(OUT)" || { echo "OUT is required"; exit 1; }
+ CLUSTER=$(DOWNSTREAM_CLUSTER_NAME) OUT=$(OUT) $(MAKE) export-kind-kubeconfig
+
+## End-to-end bootstrap (downstream → export kubeconfig → upstream)
+E2E_DOWNSTREAM_KUBECONFIG ?= dev/downstream.kubeconfig
+E2E_INSECURE ?= true
+.PHONY: bootstrap-e2e
+bootstrap-e2e: ## Bootstrap downstream, export kubeconfig, then bootstrap upstream
+ $(MAKE) bootstrap-downstream IMG=$(IMG)
+ OUT=$(E2E_DOWNSTREAM_KUBECONFIG) KIND_KUBECONFIG_INSECURE=$(E2E_INSECURE) $(MAKE) export-downstream-kubeconfig
+ DOWNSTREAM_KUBECONFIG=$(E2E_DOWNSTREAM_KUBECONFIG) IMG=$(IMG) $(MAKE) bootstrap-upstream
+ @echo "E2E bootstrap complete. Upstream: $(UPSTREAM_CLUSTER_NAME), Downstream: $(DOWNSTREAM_CLUSTER_NAME)."
+ @echo "Downstream kubeconfig: $(E2E_DOWNSTREAM_KUBECONFIG)"
+ @echo "External kubeconfigs: dev/kind.downstream.kubeconfig, dev/kind.upstream.kubeconfig"
+
+.PHONY: bootstrap-e2e-with-nso
+bootstrap-e2e-with-nso: ## Bootstrap e2e and also install+configure NSO in upstream
+ $(MAKE) bootstrap-downstream IMG=$(IMG)
+ OUT=$(E2E_DOWNSTREAM_KUBECONFIG) KIND_KUBECONFIG_INSECURE=$(E2E_INSECURE) $(MAKE) export-downstream-kubeconfig
+ DOWNSTREAM_KUBECONFIG=$(E2E_DOWNSTREAM_KUBECONFIG) IMG=$(IMG) NSO_DEPLOY=true $(MAKE) bootstrap-upstream
+ @echo "E2E bootstrap (with NSO) complete. Upstream: $(UPSTREAM_CLUSTER_NAME), Downstream: $(DOWNSTREAM_CLUSTER_NAME)."
+ @echo "Downstream kubeconfig: $(E2E_DOWNSTREAM_KUBECONFIG)"
+ @echo "External kubeconfigs: dev/kind.downstream.kubeconfig, dev/kind.upstream.kubeconfig"
+
+##@ DNS debugging
+
+# Defaults for DNS debug target
+DNS_DEBUG_CONTEXT ?= kind-dns-downstream
+DNS_DEBUG_NAMESPACE ?= dns-agent-system
+DNS_DEBUG_ZONE ?= example.com
+DNS_DEBUG_HOST ?= www
+
+.PHONY: dns-debug
+dns-debug: ## Launch a DNS tools pod and query PDNS SOA/NS/A for $(DNS_DEBUG_ZONE)
+ @echo "[dns-debug] Ensuring dnstools pod exists in $(DNS_DEBUG_NAMESPACE)"
+ @kubectl --context $(DNS_DEBUG_CONTEXT) -n $(DNS_DEBUG_NAMESPACE) get pod dnstools >/dev/null 2>&1 || \
+ kubectl --context $(DNS_DEBUG_CONTEXT) -n $(DNS_DEBUG_NAMESPACE) run dnstools --image=infoblox/dnstools:latest --restart=Never --command -- sh -c 'sleep 3600'
+ @kubectl --context $(DNS_DEBUG_CONTEXT) -n $(DNS_DEBUG_NAMESPACE) wait --for=condition=Ready pod/dnstools --timeout=90s
+ @echo "[dns-debug] Querying PDNS Service pdns-auth.$(DNS_DEBUG_NAMESPACE).svc.cluster.local"
+ @kubectl --context $(DNS_DEBUG_CONTEXT) -n $(DNS_DEBUG_NAMESPACE) exec dnstools -- \
+ sh -lc 'echo SOA:; dig +time=2 +tries=1 +short @pdns-auth.$(DNS_DEBUG_NAMESPACE).svc.cluster.local $(DNS_DEBUG_ZONE) SOA; \
+ echo NS:; dig +time=2 +tries=1 +short @pdns-auth.$(DNS_DEBUG_NAMESPACE).svc.cluster.local $(DNS_DEBUG_ZONE) NS; \
+ echo A $(DNS_DEBUG_HOST).$(DNS_DEBUG_ZONE):; dig +time=2 +tries=1 +short @pdns-auth.$(DNS_DEBUG_NAMESPACE).svc.cluster.local $(DNS_DEBUG_HOST).$(DNS_DEBUG_ZONE) A'
+
##@ Dependencies
## Location to install dependencies to
@@ -183,17 +348,26 @@ KUBECTL ?= kubectl
KIND ?= kind
KUSTOMIZE ?= $(LOCALBIN)/kustomize
CONTROLLER_GEN ?= $(LOCALBIN)/controller-gen
+DEFAULTER_GEN ?= $(LOCALBIN)/defaulter-gen
ENVTEST ?= $(LOCALBIN)/setup-envtest
GOLANGCI_LINT = $(LOCALBIN)/golangci-lint
+CHAINSAW ?= $(LOCALBIN)/chainsaw
+CMCTL ?= $(LOCALBIN)/cmctl
## Tool Versions
KUSTOMIZE_VERSION ?= v5.7.1
CONTROLLER_TOOLS_VERSION ?= v0.19.0
+DEFAULTER_GEN_VERSION ?= v0.32.3
+GATEWAY_API_VERSION ?= v1.1.0
+
#ENVTEST_VERSION is the version of controller-runtime release branch to fetch the envtest setup script (i.e. release-0.20)
ENVTEST_VERSION ?= $(shell go list -m -f "{{ .Version }}" sigs.k8s.io/controller-runtime | awk -F'[v.]' '{printf "release-%d.%d", $$2, $$3}')
#ENVTEST_K8S_VERSION is the version of Kubernetes to use for setting up ENVTEST binaries (i.e. 1.31)
ENVTEST_K8S_VERSION ?= $(shell go list -m -f "{{ .Version }}" k8s.io/api | awk -F'[v.]' '{printf "1.%d", $$3}')
GOLANGCI_LINT_VERSION ?= v2.4.0
+CHAINSAW_VERSION ?= v0.2.13
+CERTMANAGER_VERSION ?= 1.17.1
+CMCTL_VERSION ?= v2.1.1
.PHONY: kustomize
kustomize: $(KUSTOMIZE) ## Download kustomize locally if necessary.
@@ -205,6 +379,12 @@ controller-gen: $(CONTROLLER_GEN) ## Download controller-gen locally if necessar
$(CONTROLLER_GEN): $(LOCALBIN)
$(call go-install-tool,$(CONTROLLER_GEN),sigs.k8s.io/controller-tools/cmd/controller-gen,$(CONTROLLER_TOOLS_VERSION))
+.PHONY: defaulter-gen
+defaulter-gen: $(DEFAULTER_GEN) ## Download defaulter-gen locally if necessary.
+$(DEFAULTER_GEN): $(LOCALBIN)
+ $(call go-install-tool,$(DEFAULTER_GEN),k8s.io/code-generator/cmd/defaulter-gen,$(DEFAULTER_GEN_VERSION))
+
+
.PHONY: setup-envtest
setup-envtest: envtest ## Download the binaries required for ENVTEST in the local bin directory.
@echo "Setting up envtest binaries for Kubernetes version $(ENVTEST_K8S_VERSION)..."
@@ -223,6 +403,62 @@ golangci-lint: $(GOLANGCI_LINT) ## Download golangci-lint locally if necessary.
$(GOLANGCI_LINT): $(LOCALBIN)
$(call go-install-tool,$(GOLANGCI_LINT),github.com/golangci/golangci-lint/v2/cmd/golangci-lint,$(GOLANGCI_LINT_VERSION))
+.PHONY: cmctl
+cmctl: ## Find or download cmctl
+ $(call go-install-tool,$(CMCTL),github.com/cert-manager/cmctl/v2,$(CMCTL_VERSION))
+
+.PHONY: chainsaw
+chainsaw: ## Find or download chainsaw
+ $(call go-install-tool,$(CHAINSAW),github.com/kyverno/chainsaw,$(CHAINSAW_VERSION))
+
+.PHONY: chainsaw-test
+chainsaw-test: chainsaw chainsaw-prepare-kubeconfigs ## Run Chainsaw tests (requires dev/kind.*.kubeconfig to exist)
+ @test -f dev/kind.upstream.kubeconfig || { echo "Missing dev/kind.upstream.kubeconfig. Bootstrap clusters first."; exit 1; }
+ @test -f dev/kind.downstream.kubeconfig || { echo "Missing dev/kind.downstream.kubeconfig. Bootstrap clusters first."; exit 1; }
+ cd test/e2e && $(CHAINSAW) test .
+
+.PHONY: chainsaw-prepare-kubeconfigs
+chainsaw-prepare-kubeconfigs: ## Copy dev kind kubeconfigs into test/e2e for stable relative resolution
+ @test -f dev/kind.upstream.kubeconfig || { echo "Missing dev/kind.upstream.kubeconfig. Bootstrap clusters first."; exit 1; }
+ @test -f dev/kind.downstream.kubeconfig || { echo "Missing dev/kind.downstream.kubeconfig. Bootstrap clusters first."; exit 1; }
+ cp dev/kind.upstream.kubeconfig test/e2e/kubeconfig-upstream
+ cp dev/kind.downstream.kubeconfig test/e2e/kubeconfig-downstream
+
+.PHONY: nso-clone
+nso-clone: ## Clone network-services-operator into dev if missing
+ @test -d $(NSO_DIR) || { \
+ echo "Cloning $(NSO_REPO) into $(NSO_DIR)"; \
+ git clone $(NSO_REPO) $(NSO_DIR); \
+ }
+
+.PHONY: nso-build
+nso-build: nso-clone ## Build NSO image locally
+ $(MAKE) -C $(NSO_DIR) docker-build IMG=$(NSO_IMG)
+
+.PHONY: nso-load-upstream
+nso-load-upstream: ## Load NSO image into upstream kind cluster
+ $(KIND) load docker-image $(NSO_IMG) --name $(UPSTREAM_CLUSTER_NAME)
+
+.PHONY: nso-deploy-upstream
+nso-deploy-upstream: nso-build nso-load-upstream ## Deploy NSO into upstream cluster using KUBECONFIG
+ @test -n "$(KUBECONFIG)" || { echo "KUBECONFIG is required (e.g., dev/kind.upstream.kubeconfig)"; exit 1; }
+ KUBECONFIG=$(abspath $(KUBECONFIG)) $(MAKE) -C $(NSO_DIR) set-image-controller IMG=$(NSO_IMG)
+ KUBECONFIG=$(abspath $(KUBECONFIG)) $(MAKE) -C $(NSO_DIR) deploy IMG=$(NSO_IMG)
+
+.PHONY: install-networking-crds
+install-networking-crds: controller-gen ## Generate and install networking services CRDs (e.g., Domain) into CONTEXT
+ @test -n "$(CONTEXT)" || { echo "CONTEXT is required (e.g., kind-$(UPSTREAM_CLUSTER_NAME))"; exit 1; }
+ mkdir -p dev/crds/network-services
+ $(CONTROLLER_GEN) crd:crdVersions=v1 \
+ paths="go.datum.net/network-services-operator/api/v1alpha" \
+ output:crd:dir=dev/crds/network-services
+ $(KUBECTL) --context $(CONTEXT) apply -f dev/crds/network-services
+
+.PHONY: install-gateway-api-crds
+install-gateway-api-crds: ## Install Kubernetes Gateway API CRDs into CONTEXT
+ @test -n "$(CONTEXT)" || { echo "CONTEXT is required (e.g., kind-$(UPSTREAM_CLUSTER_NAME))"; exit 1; }
+ $(KUBECTL) --context $(CONTEXT) apply -k "github.com/kubernetes-sigs/gateway-api/config/crd?ref=$(GATEWAY_API_VERSION)"
+
# go-install-tool will 'go install' any package with custom target and name of binary, if it doesn't exist
# $1 - target path with name of binary
# $2 - package url which can be installed
diff --git a/PROJECT b/PROJECT
index f885a11..729939b 100644
--- a/PROJECT
+++ b/PROJECT
@@ -34,4 +34,13 @@ resources:
kind: DNSRecordSet
path: go.miloapis.com/dns-operator/api/v1alpha1
version: v1alpha1
+- api:
+ crdVersion: v1
+ namespaced: true
+ controller: true
+ domain: networking.miloapis.com
+ group: dns
+ kind: DNSZoneDiscovery
+ path: go.miloapis.com/dns-operator/api/v1alpha1
+ version: v1alpha1
version: "3"
diff --git a/README.md b/README.md
index 7944d95..faf0c93 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,135 @@
-# dns-operator
\ No newline at end of file
+## datum-dns-operator
+
+Kubernetes operator for managing DNS zones and records, with a pluggable backend architecture. This repository provides:
+- Custom resources to model zones, recordsets, and zone classes
+- Controllers for two runtime roles:
+ - "downstream" agent that programs a DNS backend (PowerDNS supported)
+ - "replicator" that mirrors resources from an upstream cluster to a downstream cluster and synthesizes status
+- Kustomize overlays to deploy either role
+
+### CRDs
+- **`DNSZoneClass`** (cluster-scoped)
+ - `spec.controllerName`: selects backend controller (e.g., "powerdns")
+ - `spec.nameServerPolicy`: currently supports `Static` with `servers: []`
+ - `spec.defaults.defaultTTL`: optional default TTL for zones
+
+- **`DNSZone`** (namespaced)
+ - `spec.domainName`: required zone FQDN (e.g., `example.com`)
+ - `spec.dnsZoneClassName`: optional reference to a `DNSZoneClass`
+ - `status.nameservers`: authoritative nameservers (derived from class policy)
+ - `status.conditions`: `Accepted`, `Programmed`
+
+- **`DNSRecordSet`** (namespaced)
+ - `spec.dnsZoneRef`: `LocalObjectReference` to a `DNSZone` in the same namespace
+ - `spec.recordType`: one of `A, AAAA, CNAME, TXT, MX, SRV, CAA, NS, SOA, PTR, TLSA, HTTPS, SVCB`
+ - `spec.records[]`: owners with typed fields per record type (or `raw` strings). TTL per-owner optional.
+ - `status.conditions`: `Accepted`, `Programmed`
+
+### Controllers and Roles
+
+- **Downstream role** (`--role=downstream`)
+ - `DNSZoneReconciler`: when `DNSZone.spec.dnsZoneClassName` references a class with `controllerName: powerdns`, ensures the zone exists in PowerDNS and honors static nameserver policy.
+ - `DNSRecordSetReconciler`: for PowerDNS-backed zones, applies recordsets to PDNS using an authoritative mode that REPLACEs desired owners and DELETEs extraneous owners of the same type. Requeues while the zone is not ready.
+
+- **Replicator role** (`--role=replicator`)
+ - Multicluster manager discovers one or many upstream clusters (single-cluster or Milo discovery) and mirrors `DNSZone`/`DNSRecordSet` into a configured downstream cluster using a mapped-namespace strategy.
+ - `DNSZoneReplicator`:
+ - Mirrors upstream `spec` into a downstream shadow object
+ - Ensures an operator-managed upstream `DNSRecordSet` named `soa` exists (typed SOA targeting `@`) for PowerDNS-backed zones
+ - Updates upstream `status`: sets `Accepted=True` and currently treats `Programmed=True` optimistically; fills `status.nameservers` from `DNSZoneClass` when `Static` policy is set
+ - `DNSRecordSetReplicator`:
+ - Mirrors upstream `spec` into a downstream shadow object
+ - Updates upstream `status`: `Accepted` reflects `DNSZone` presence; `Programmed=True` once downstream shadow ensured
+
+### Backends
+- **PowerDNS (Authoritative)**
+ - Enabled when `DNSZoneClass.spec.controllerName: powerdns`
+ - The downstream agent uses environment variables to connect:
+ - `PDNS_API_URL` (default `http://127.0.0.1:8081`)
+ - `PDNS_API_KEY` or `PDNS_API_KEY_FILE`
+ - Recordset translation supports typed fields for all declared RR types and sensible normalization of names and quoting for TXT/targets.
+
+### Deployment Overlays
+
+- `config/agent/`
+ - Namespace: `dns-agent-system`
+ - Runs the operator with `--role=downstream`
+ - Merges a `pdns` sidecar container into the controller Deployment to run PowerDNS alongside the manager
+ - Mounts a shared `emptyDir` to exchange an auto-generated API key, and sets `PDNS_API_KEY_FILE` in the manager
+ - Provides a `Service` exposing PDNS ports 53/udp, 53/tcp, and 8081/tcp
+ - ConfigMap `server-config` wired to `--server-config`
+
+- `config/overlays/replicator/`
+ - Namespace: `dns-replicator-system`
+ - Runs the operator with `--role=replicator`
+ - Requires a Secret `downstream-kubeconfig` containing key `kubeconfig` to target the downstream cluster
+ - ConfigMap `server-config` sets discovery mode (defaults to `single`) and points `downstreamResourceManagement.kubeconfigPath` to `/downstream/kubeconfig`
+
+### Quickstart: Agent with embedded PowerDNS
+1. Install CRDs and default manifests:
+ - `kubectl apply -k config/agent`
+2. Create a `DNSZoneClass` for PowerDNS with static nameservers, for example:
+```yaml
+apiVersion: dns.networking.miloapis.com/v1alpha1
+kind: DNSZoneClass
+metadata:
+ name: powerdns
+spec:
+ controllerName: powerdns
+ nameServerPolicy:
+ mode: Static
+ static:
+ servers: ["ns1.example.net.", "ns2.example.net."]
+```
+3. Create a `DNSZone` and a `DNSRecordSet`:
+```yaml
+apiVersion: dns.networking.miloapis.com/v1alpha1
+kind: DNSZone
+metadata:
+ name: example-com
+ namespace: default
+spec:
+ domainName: example.com
+ dnsZoneClassName: powerdns
+---
+apiVersion: dns.networking.miloapis.com/v1alpha1
+kind: DNSRecordSet
+metadata:
+ name: www-a
+ namespace: default
+spec:
+ dnsZoneRef:
+ name: example-com
+ recordType: A
+ records:
+ - name: www
+ a:
+ content: ["192.0.2.10", "192.0.2.11"]
+ ttl: 300
+```
+
+### Quickstart: Replicator (upstream → downstream)
+1. Create Secret on the replicator namespace containing the downstream kubeconfig (`data.kubeconfig`):
+```bash
+kubectl -n dns-replicator-system create secret generic downstream-kubeconfig \
+ --from-file=kubeconfig=/path/to/downstream/kubeconfig
+```
+2. Deploy replicator overlay:
+```bash
+kubectl apply -k config/overlays/replicator
+```
+3. Create `DNSZoneClass` (cluster-scoped), `DNSZone` and `DNSRecordSet` on the upstream cluster. The replicator will mirror them into the downstream cluster and update upstream `status` conditions.
+
+### Conditions
+- `Accepted`: resource is valid and has required dependencies (e.g., `DNSRecordSet` sees its `DNSZone`)
+- `Programmed`: desired state is realized (shadow exists downstream; for downstream agent, recordsets applied to backend)
+
+### Configuration CRD (server config)
+- `kind: DNSOperator` (internal config consumed by the binary via `--server-config`)
+ - `discovery.mode`: `single` or `milo`
+ - `downstreamResourceManagement.kubeconfigPath`: path inside the Pod to the downstream kubeconfig
+
+### Development
+- Build: `make docker-build` (see `Makefile`)
+- Generate code/manifests: `make generate` and `make manifests`
+- Local e2e: see `test/e2e/chainsaw-test.yaml` and sample manifests under `config/samples/`
diff --git a/api/v1alpha1/dnsrecordset_types.go b/api/v1alpha1/dnsrecordset_types.go
index 2fd74a1..98eabd9 100644
--- a/api/v1alpha1/dnsrecordset_types.go
+++ b/api/v1alpha1/dnsrecordset_types.go
@@ -1,18 +1,4 @@
-/*
-Copyright 2025.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
+// SPDX-License-Identifier: AGPL-3.0-only
package v1alpha1
@@ -43,78 +29,130 @@ const (
// DNSRecordSetSpec defines the desired state of DNSRecordSet
type DNSRecordSetSpec struct {
// DNSZoneRef references the DNSZone (same namespace) this recordset belongs to.
+ // +kubebuilder:validation:Required
+ // +kubebuilder:validation:XValidation:rule="self.name != ''",message="dnsZoneRef.name must be set"
DNSZoneRef corev1.LocalObjectReference `json:"dnsZoneRef"`
// RecordType is the DNS RR type for this recordset.
+ // +kubebuilder:validation:Required
RecordType RRType `json:"recordType"`
// Records contains one or more owner names with values appropriate for the RecordType.
+ // +kubebuilder:validation:MinItems=1
Records []RecordEntry `json:"records"`
}
// RecordEntry represents one owner name and its values.
type RecordEntry struct {
// Name is the owner name (relative to the zone or FQDN).
+ // +kubebuilder:validation:Required
+ // +kubebuilder:validation:MinLength=1
+ // +kubebuilder:validation:Pattern=`^(@|[A-Za-z0-9*._-]+)$`
Name string `json:"name"`
// TTL optionally overrides TTL for this owner/RRset.
// +optional
TTL *int64 `json:"ttl,omitempty"`
- // Raw contains raw RDATA strings when used instead of typed fields.
- // +optional
- Raw []string `json:"raw,omitempty"`
-
// Exactly one of the following type-specific fields should be set matching RecordType.
// +optional
- A *SimpleValues `json:"a,omitempty"`
+ A *ARecordSpec `json:"a,omitempty"`
// +optional
- AAAA *SimpleValues `json:"aaaa,omitempty"`
+ AAAA *AAAARecordSpec `json:"aaaa,omitempty"`
// +optional
- CNAME *CNAMEValue `json:"cname,omitempty"`
+ CNAME *CNAMERecordSpec `json:"cname,omitempty"`
// +optional
- TXT *SimpleValues `json:"txt,omitempty"`
+ NS *NSRecordSpec `json:"ns,omitempty"`
// +optional
- CAA []CAARecordSpec `json:"caa,omitempty"`
+ TXT *TXTRecordSpec `json:"txt,omitempty"`
// +optional
- MX []MXRecordSpec `json:"mx,omitempty"`
+ SOA *SOARecordSpec `json:"soa,omitempty"`
// +optional
- SRV []SRVRecordSpec `json:"srv,omitempty"`
+ CAA *CAARecordSpec `json:"caa,omitempty"`
// +optional
- TLSA []TLSARecordSpec `json:"tlsa,omitempty"`
+ MX *MXRecordSpec `json:"mx,omitempty"`
// +optional
- HTTPS []HTTPSRecordSpec `json:"https,omitempty"`
+ SRV *SRVRecordSpec `json:"srv,omitempty"`
+ // +optional
+ TLSA *TLSARecordSpec `json:"tlsa,omitempty"`
+ // +optional
+ HTTPS *HTTPSRecordSpec `json:"https,omitempty"`
+ // +optional
+ SVCB *HTTPSRecordSpec `json:"svcb,omitempty"`
+
// +optional
- SVCB []HTTPSRecordSpec `json:"svcb,omitempty"`
+ PTR *PTRRecordSpec `json:"ptr,omitempty"`
}
-type SimpleValues struct {
- Content []string `json:"content"`
+type PTRRecordSpec struct {
+ Content string `json:"content"`
}
-type CNAMEValue struct {
+type TXTRecordSpec struct {
+ Content string `json:"content"`
+}
+
+type ARecordSpec struct {
+ // +kubebuilder:validation:Format=ipv4
+ Content string `json:"content"`
+}
+
+type AAAARecordSpec struct {
+ // +kubebuilder:validation:Format=ipv6
+ Content string `json:"content"`
+}
+
+type CNAMERecordSpec struct {
+ // +kubebuilder:validation:MaxLength=253
+ // +kubebuilder:validation:Pattern=`^([A-Za-z0-9_](?:[-A-Za-z0-9_]{0,61}[A-Za-z0-9_])?)(?:\.([A-Za-z0-9_](?:[-A-Za-z0-9_]{0,61}[A-Za-z0-9_])?))*\.?$`
+ // +kubebuilder:validation:MinLength=1
+ Content string `json:"content"`
+}
+
+type NSRecordSpec struct {
+ // +kubebuilder:validation:MinLength=1
+ // +kubebuilder:validation:MaxLength=253
+ // Require a hostname (FQDN or relative), allow optional trailing dot, no underscores.
+ // Labels: 1-63 chars, alphanum with interior hyphens, total length <=253.
+ // +kubebuilder:validation:Pattern=`^([A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)(?:\.([A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?))*\.?$`
Content string `json:"content"`
}
type SRVRecordSpec struct {
+ // +kubebuilder:validation:Minimum=0
+ // +kubebuilder:validation:Maximum=65535
Priority uint16 `json:"priority"`
- Weight uint16 `json:"weight"`
- Port uint16 `json:"port"`
- Target string `json:"target"`
+ // +kubebuilder:validation:Minimum=0
+ // +kubebuilder:validation:Maximum=65535
+ Weight uint16 `json:"weight"`
+ // +kubebuilder:validation:Minimum=0
+ // +kubebuilder:validation:Maximum=65535
+ Port uint16 `json:"port"`
+ // +kubebuilder:validation:MinLength=1
+ Target string `json:"target"`
}
type MXRecordSpec struct {
+ // +kubebuilder:validation:Minimum=0
+ // +kubebuilder:validation:Maximum=65535
Preference uint16 `json:"preference"`
- Exchange string `json:"exchange"`
+ // +kubebuilder:validation:MinLength=1
+ Exchange string `json:"exchange"`
}
type CAARecordSpec struct {
- Flag uint8 `json:"flag"`
- Tag string `json:"tag"`
+ // 0–255 flag
+ // +kubebuilder:validation:Minimum=0
+ // +kubebuilder:validation:Maximum=255
+ Flag uint8 `json:"flag"`
+ // RFC-style tags: keep it simple: [a-z0-9]+
+ // +kubebuilder:validation:MinLength=1
+ // +kubebuilder:validation:Pattern=`^[a-z0-9]+$`
+ Tag string `json:"tag"`
+ // +kubebuilder:validation:MinLength=1
Value string `json:"value"`
}
type TLSARecordSpec struct {
- // Common TLSA tuple: usage, selector, matching type, value
Usage uint8 `json:"usage"`
Selector uint8 `json:"selector"`
MatchingType uint8 `json:"matchingType"`
@@ -122,17 +160,33 @@ type TLSARecordSpec struct {
}
type HTTPSRecordSpec struct {
- Priority uint16 `json:"priority"`
- Target string `json:"target"`
- Params map[string]string `json:"params,omitempty"`
+ // +kubebuilder:validation:Minimum=0
+ // +kubebuilder:validation:Maximum=65535
+ Priority uint16 `json:"priority"`
+ Target string `json:"target"`
+ // +optional
+ Params map[string]string `json:"params,omitempty"`
}
-// DNSRecordSetStatus defines the observed state of DNSRecordSet.
-type DNSRecordSetStatus struct {
- // ObservedGeneration is the last processed generation.
+type SOARecordSpec struct {
+ // +kubebuilder:validation:MinLength=1
+ MName string `json:"mname"`
+ // +kubebuilder:validation:MinLength=1
+ RName string `json:"rname"`
+ // +optional
+ Serial uint32 `json:"serial,omitempty"`
+ // +optional
+ Refresh uint32 `json:"refresh,omitempty"`
+ // +optional
+ Retry uint32 `json:"retry,omitempty"`
// +optional
- ObservedGeneration int64 `json:"observedGeneration,omitempty"`
+ Expire uint32 `json:"expire,omitempty"`
+ // +optional
+ TTL uint32 `json:"ttl,omitempty"`
+}
+// DNSRecordSetStatus defines the observed state of DNSRecordSet.
+type DNSRecordSetStatus struct {
// Conditions includes Accepted and Programmed readiness.
// +listType=map
// +listMapKey=type
@@ -144,6 +198,8 @@ type DNSRecordSetStatus struct {
// +kubebuilder:subresource:status
// +kubebuilder:printcolumn:name="Accepted",type=string,JSONPath=.status.conditions[?(@.type=="Accepted")].status
// +kubebuilder:printcolumn:name="Programmed",type=string,JSONPath=.status.conditions[?(@.type=="Programmed")].status
+// +kubebuilder:selectablefield:JSONPath=".spec.dnsZoneRef.name"
+// +kubebuilder:selectablefield:JSONPath=".spec.recordType"
// DNSRecordSet is the Schema for the dnsrecordsets API
type DNSRecordSet struct {
diff --git a/api/v1alpha1/dnszone_types.go b/api/v1alpha1/dnszone_types.go
index dd4ffbe..399f3c4 100644
--- a/api/v1alpha1/dnszone_types.go
+++ b/api/v1alpha1/dnszone_types.go
@@ -1,33 +1,36 @@
-/*
-Copyright 2025.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
+// SPDX-License-Identifier: AGPL-3.0-only
package v1alpha1
import (
+ networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// DNSZoneSpec defines the desired state of DNSZone
type DNSZoneSpec struct {
// DomainName is the FQDN of the zone (e.g., "example.com").
+ //
+ // +kubebuilder:validation:Required
+ // +kubebuilder:validation:MinLength=1
+ // +kubebuilder:validation:MaxLength=253
+ // +kubebuilder:validation:Pattern=`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`
+ // +kubebuilder:validation:XValidation:message="A domain name is immutable and cannot be changed after creation",rule="oldSelf == '' || self == oldSelf"
+ // +kubebuilder:validation:XValidation:message="Must have at least two segments separated by dots",rule="self.indexOf('.') != -1"
DomainName string `json:"domainName"`
// DNSZoneClassName references the DNSZoneClass used to provision this zone.
- // +optional
- DNSZoneClassName string `json:"dnsZoneClassName,omitempty"`
+ // +kubebuilder:validation:Required
+ DNSZoneClassName string `json:"dnsZoneClassName"`
+}
+
+type DomainRefStatus struct {
+ Nameservers []networkingv1alpha.Nameserver `json:"nameservers,omitempty"`
+}
+
+type DomainRef struct {
+ Name string `json:"name"`
+ Status DomainRefStatus `json:"status,omitempty"`
}
// DNSZoneStatus defines the observed state of DNSZone.
@@ -36,17 +39,26 @@ type DNSZoneStatus struct {
// +optional
Nameservers []string `json:"nameservers,omitempty"`
+ // RecordCount is the number of DNSRecordSet resources in this namespace that reference this zone.
+ // +optional
+ RecordCount int `json:"recordCount,omitempty"`
+
// Conditions tracks state such as Accepted and Programmed readiness.
// +listType=map
// +listMapKey=type
// +optional
Conditions []metav1.Condition `json:"conditions,omitempty"`
+
+ // DomainRef references the Domain this zone belongs to.
+ // +optional
+ DomainRef *DomainRef `json:"domainRef,omitempty"`
}
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:printcolumn:name="Accepted",type=string,JSONPath=.status.conditions[?(@.type=="Accepted")].status
// +kubebuilder:printcolumn:name="Programmed",type=string,JSONPath=.status.conditions[?(@.type=="Programmed")].status
+// +kubebuilder:printcolumn:name="Records",type=integer,JSONPath=.status.recordCount
// DNSZone is the Schema for the dnszones API
type DNSZone struct {
diff --git a/api/v1alpha1/dnszoneclass_types.go b/api/v1alpha1/dnszoneclass_types.go
index 0a36ad8..297b3a0 100644
--- a/api/v1alpha1/dnszoneclass_types.go
+++ b/api/v1alpha1/dnszoneclass_types.go
@@ -1,18 +1,4 @@
-/*
-Copyright 2025.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
+// SPDX-License-Identifier: AGPL-3.0-only
package v1alpha1
@@ -23,10 +9,11 @@ import (
// DNSZoneClassSpec defines the desired state of DNSZoneClass
type DNSZoneClassSpec struct {
// ControllerName identifies the downstream controller/backend implementation (e.g., "powerdns", "hickory").
+ // +kubebuilder:validation:Required
ControllerName string `json:"controllerName"`
// NameServerPolicy defines how nameservers are assigned for zones using this class.
- NameServerPolicy NameServerPolicy `json:"nameServerPolicy"`
+ NameServerPolicy *NameServerPolicy `json:"nameServerPolicy,omitempty"`
// Defaults provides optional default values applied to managed zones.
// +optional
diff --git a/api/v1alpha1/dnszonediscovery_types.go b/api/v1alpha1/dnszonediscovery_types.go
new file mode 100644
index 0000000..90ec89f
--- /dev/null
+++ b/api/v1alpha1/dnszonediscovery_types.go
@@ -0,0 +1,80 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+//
+// One-shot discovery/snapshot of existing DNS records for a DNSZone.
+// On creation, a controller queries common RR types for the zone and stores
+// them in .status for easy extraction/translation into DNSRecordSet objects.
+// This object is write-once (status) and has no lifecycle beyond initial discovery.
+package v1alpha1
+
+import (
+ corev1 "k8s.io/api/core/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// DNSZoneDiscoverySpec defines the desired discovery target.
+type DNSZoneDiscoverySpec struct {
+ // DNSZoneRef references the DNSZone (same namespace) this discovery targets.
+ // +kubebuilder:validation:Required
+ DNSZoneRef corev1.LocalObjectReference `json:"dnsZoneRef"`
+}
+
+// DiscoveredRecordSet groups discovered records by type.
+type DiscoveredRecordSet struct {
+ // RecordType is the DNS RR type for this recordset.
+ // +kubebuilder:validation:Required
+ RecordType RRType `json:"recordType"`
+
+ // Records contains one or more owner names with values appropriate for the RecordType.
+ // The RecordEntry schema is shared with DNSRecordSet for easy translation.
+ Records []RecordEntry `json:"records"`
+}
+
+// DNSZoneDiscoveryStatus defines the observed snapshot of a DNS zone.
+type DNSZoneDiscoveryStatus struct {
+ // Conditions includes Accepted and Discovered.
+ // +listType=map
+ // +listMapKey=type
+ // +optional
+ Conditions []metav1.Condition `json:"conditions,omitempty"`
+
+ // RecordSets is the set of discovered RRsets grouped by RecordType.
+ // +optional
+ RecordSets []DiscoveredRecordSet `json:"recordSets,omitempty"`
+}
+
+// +kubebuilder:object:root=true
+// +kubebuilder:subresource:status
+// +kubebuilder:printcolumn:name="Accepted",type=string,JSONPath=.status.conditions[?(@.type=="Accepted")].status
+// +kubebuilder:printcolumn:name="Discovered",type=string,JSONPath=.status.conditions[?(@.type=="Discovered")].status
+// +kubebuilder:selectablefield:JSONPath=".spec.dnsZoneRef.name"
+// +kubebuilder:resource:path=dnszonediscoveries,shortName=dnszd
+
+// DNSZoneDiscovery is the Schema for the DNSZone discovery API.
+type DNSZoneDiscovery struct {
+ metav1.TypeMeta `json:",inline"`
+
+ // metadata is a standard object metadata
+ // +optional
+ metav1.ObjectMeta `json:"metadata,omitempty,omitzero"`
+
+ // spec defines the desired target for discovery.
+ // +required
+ Spec DNSZoneDiscoverySpec `json:"spec"`
+
+ // status contains the discovered data (write-once).
+ // +optional
+ Status DNSZoneDiscoveryStatus `json:"status,omitempty,omitzero"`
+}
+
+// +kubebuilder:object:root=true
+
+// DNSZoneDiscoveryList contains a list of DNSZoneDiscovery
+type DNSZoneDiscoveryList struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ListMeta `json:"metadata,omitempty"`
+ Items []DNSZoneDiscovery `json:"items"`
+}
+
+func init() {
+ SchemeBuilder.Register(&DNSZoneDiscovery{}, &DNSZoneDiscoveryList{})
+}
diff --git a/api/v1alpha1/groupversion_info.go b/api/v1alpha1/groupversion_info.go
index fe60adb..6d160ee 100644
--- a/api/v1alpha1/groupversion_info.go
+++ b/api/v1alpha1/groupversion_info.go
@@ -1,18 +1,4 @@
-/*
-Copyright 2025.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
+// SPDX-License-Identifier: AGPL-3.0-only
// Package v1alpha1 contains API Schema definitions for the dns v1alpha1 API group.
// +kubebuilder:object:generate=true
diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go
index a14cdf5..8e034e5 100644
--- a/api/v1alpha1/zz_generated.deepcopy.go
+++ b/api/v1alpha1/zz_generated.deepcopy.go
@@ -1,30 +1,47 @@
//go:build !ignore_autogenerated
-/*
-Copyright 2025.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
+// SPDX-License-Identifier: AGPL-3.0-only
// Code generated by controller-gen. DO NOT EDIT.
package v1alpha1
import (
+ "go.datum.net/network-services-operator/api/v1alpha"
"k8s.io/apimachinery/pkg/apis/meta/v1"
runtime "k8s.io/apimachinery/pkg/runtime"
)
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *AAAARecordSpec) DeepCopyInto(out *AAAARecordSpec) {
+ *out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AAAARecordSpec.
+func (in *AAAARecordSpec) DeepCopy() *AAAARecordSpec {
+ if in == nil {
+ return nil
+ }
+ out := new(AAAARecordSpec)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ARecordSpec) DeepCopyInto(out *ARecordSpec) {
+ *out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ARecordSpec.
+func (in *ARecordSpec) DeepCopy() *ARecordSpec {
+ if in == nil {
+ return nil
+ }
+ out := new(ARecordSpec)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *CAARecordSpec) DeepCopyInto(out *CAARecordSpec) {
*out = *in
@@ -41,16 +58,16 @@ func (in *CAARecordSpec) DeepCopy() *CAARecordSpec {
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
-func (in *CNAMEValue) DeepCopyInto(out *CNAMEValue) {
+func (in *CNAMERecordSpec) DeepCopyInto(out *CNAMERecordSpec) {
*out = *in
}
-// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CNAMEValue.
-func (in *CNAMEValue) DeepCopy() *CNAMEValue {
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CNAMERecordSpec.
+func (in *CNAMERecordSpec) DeepCopy() *CNAMERecordSpec {
if in == nil {
return nil
}
- out := new(CNAMEValue)
+ out := new(CNAMERecordSpec)
in.DeepCopyInto(out)
return out
}
@@ -248,7 +265,11 @@ func (in *DNSZoneClassList) DeepCopyObject() runtime.Object {
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *DNSZoneClassSpec) DeepCopyInto(out *DNSZoneClassSpec) {
*out = *in
- in.NameServerPolicy.DeepCopyInto(&out.NameServerPolicy)
+ if in.NameServerPolicy != nil {
+ in, out := &in.NameServerPolicy, &out.NameServerPolicy
+ *out = new(NameServerPolicy)
+ (*in).DeepCopyInto(*out)
+ }
if in.Defaults != nil {
in, out := &in.Defaults, &out.Defaults
*out = new(ZoneDefaults)
@@ -288,6 +309,110 @@ func (in *DNSZoneClassStatus) DeepCopy() *DNSZoneClassStatus {
return out
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *DNSZoneDiscovery) DeepCopyInto(out *DNSZoneDiscovery) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+ out.Spec = in.Spec
+ in.Status.DeepCopyInto(&out.Status)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSZoneDiscovery.
+func (in *DNSZoneDiscovery) DeepCopy() *DNSZoneDiscovery {
+ if in == nil {
+ return nil
+ }
+ out := new(DNSZoneDiscovery)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *DNSZoneDiscovery) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *DNSZoneDiscoveryList) DeepCopyInto(out *DNSZoneDiscoveryList) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ListMeta.DeepCopyInto(&out.ListMeta)
+ if in.Items != nil {
+ in, out := &in.Items, &out.Items
+ *out = make([]DNSZoneDiscovery, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSZoneDiscoveryList.
+func (in *DNSZoneDiscoveryList) DeepCopy() *DNSZoneDiscoveryList {
+ if in == nil {
+ return nil
+ }
+ out := new(DNSZoneDiscoveryList)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *DNSZoneDiscoveryList) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *DNSZoneDiscoverySpec) DeepCopyInto(out *DNSZoneDiscoverySpec) {
+ *out = *in
+ out.DNSZoneRef = in.DNSZoneRef
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSZoneDiscoverySpec.
+func (in *DNSZoneDiscoverySpec) DeepCopy() *DNSZoneDiscoverySpec {
+ if in == nil {
+ return nil
+ }
+ out := new(DNSZoneDiscoverySpec)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *DNSZoneDiscoveryStatus) DeepCopyInto(out *DNSZoneDiscoveryStatus) {
+ *out = *in
+ if in.Conditions != nil {
+ in, out := &in.Conditions, &out.Conditions
+ *out = make([]v1.Condition, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+ if in.RecordSets != nil {
+ in, out := &in.RecordSets, &out.RecordSets
+ *out = make([]DiscoveredRecordSet, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSZoneDiscoveryStatus.
+func (in *DNSZoneDiscoveryStatus) DeepCopy() *DNSZoneDiscoveryStatus {
+ if in == nil {
+ return nil
+ }
+ out := new(DNSZoneDiscoveryStatus)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *DNSZoneList) DeepCopyInto(out *DNSZoneList) {
*out = *in
@@ -350,6 +475,11 @@ func (in *DNSZoneStatus) DeepCopyInto(out *DNSZoneStatus) {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
+ if in.DomainRef != nil {
+ in, out := &in.DomainRef, &out.DomainRef
+ *out = new(DomainRef)
+ (*in).DeepCopyInto(*out)
+ }
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSZoneStatus.
@@ -362,6 +492,66 @@ func (in *DNSZoneStatus) DeepCopy() *DNSZoneStatus {
return out
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *DiscoveredRecordSet) DeepCopyInto(out *DiscoveredRecordSet) {
+ *out = *in
+ if in.Records != nil {
+ in, out := &in.Records, &out.Records
+ *out = make([]RecordEntry, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DiscoveredRecordSet.
+func (in *DiscoveredRecordSet) DeepCopy() *DiscoveredRecordSet {
+ if in == nil {
+ return nil
+ }
+ out := new(DiscoveredRecordSet)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *DomainRef) DeepCopyInto(out *DomainRef) {
+ *out = *in
+ in.Status.DeepCopyInto(&out.Status)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DomainRef.
+func (in *DomainRef) DeepCopy() *DomainRef {
+ if in == nil {
+ return nil
+ }
+ out := new(DomainRef)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *DomainRefStatus) DeepCopyInto(out *DomainRefStatus) {
+ *out = *in
+ if in.Nameservers != nil {
+ in, out := &in.Nameservers, &out.Nameservers
+ *out = make([]v1alpha.Nameserver, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DomainRefStatus.
+func (in *DomainRefStatus) DeepCopy() *DomainRefStatus {
+ if in == nil {
+ return nil
+ }
+ out := new(DomainRefStatus)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *HTTPSRecordSpec) DeepCopyInto(out *HTTPSRecordSpec) {
*out = *in
@@ -399,6 +589,21 @@ func (in *MXRecordSpec) DeepCopy() *MXRecordSpec {
return out
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *NSRecordSpec) DeepCopyInto(out *NSRecordSpec) {
+ *out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NSRecordSpec.
+func (in *NSRecordSpec) DeepCopy() *NSRecordSpec {
+ if in == nil {
+ return nil
+ }
+ out := new(NSRecordSpec)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *NameServerPolicy) DeepCopyInto(out *NameServerPolicy) {
*out = *in
@@ -419,6 +624,21 @@ func (in *NameServerPolicy) DeepCopy() *NameServerPolicy {
return out
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *PTRRecordSpec) DeepCopyInto(out *PTRRecordSpec) {
+ *out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PTRRecordSpec.
+func (in *PTRRecordSpec) DeepCopy() *PTRRecordSpec {
+ if in == nil {
+ return nil
+ }
+ out := new(PTRRecordSpec)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *RecordEntry) DeepCopyInto(out *RecordEntry) {
*out = *in
@@ -427,64 +647,70 @@ func (in *RecordEntry) DeepCopyInto(out *RecordEntry) {
*out = new(int64)
**out = **in
}
- if in.Raw != nil {
- in, out := &in.Raw, &out.Raw
- *out = make([]string, len(*in))
- copy(*out, *in)
- }
if in.A != nil {
in, out := &in.A, &out.A
- *out = new(SimpleValues)
- (*in).DeepCopyInto(*out)
+ *out = new(ARecordSpec)
+ **out = **in
}
if in.AAAA != nil {
in, out := &in.AAAA, &out.AAAA
- *out = new(SimpleValues)
- (*in).DeepCopyInto(*out)
+ *out = new(AAAARecordSpec)
+ **out = **in
}
if in.CNAME != nil {
in, out := &in.CNAME, &out.CNAME
- *out = new(CNAMEValue)
+ *out = new(CNAMERecordSpec)
+ **out = **in
+ }
+ if in.NS != nil {
+ in, out := &in.NS, &out.NS
+ *out = new(NSRecordSpec)
**out = **in
}
if in.TXT != nil {
in, out := &in.TXT, &out.TXT
- *out = new(SimpleValues)
- (*in).DeepCopyInto(*out)
+ *out = new(TXTRecordSpec)
+ **out = **in
+ }
+ if in.SOA != nil {
+ in, out := &in.SOA, &out.SOA
+ *out = new(SOARecordSpec)
+ **out = **in
}
if in.CAA != nil {
in, out := &in.CAA, &out.CAA
- *out = make([]CAARecordSpec, len(*in))
- copy(*out, *in)
+ *out = new(CAARecordSpec)
+ **out = **in
}
if in.MX != nil {
in, out := &in.MX, &out.MX
- *out = make([]MXRecordSpec, len(*in))
- copy(*out, *in)
+ *out = new(MXRecordSpec)
+ **out = **in
}
if in.SRV != nil {
in, out := &in.SRV, &out.SRV
- *out = make([]SRVRecordSpec, len(*in))
- copy(*out, *in)
+ *out = new(SRVRecordSpec)
+ **out = **in
}
if in.TLSA != nil {
in, out := &in.TLSA, &out.TLSA
- *out = make([]TLSARecordSpec, len(*in))
- copy(*out, *in)
+ *out = new(TLSARecordSpec)
+ **out = **in
}
if in.HTTPS != nil {
in, out := &in.HTTPS, &out.HTTPS
- *out = make([]HTTPSRecordSpec, len(*in))
- for i := range *in {
- (*in)[i].DeepCopyInto(&(*out)[i])
- }
+ *out = new(HTTPSRecordSpec)
+ (*in).DeepCopyInto(*out)
}
if in.SVCB != nil {
in, out := &in.SVCB, &out.SVCB
- *out = make([]HTTPSRecordSpec, len(*in))
- for i := range *in {
- (*in)[i].DeepCopyInto(&(*out)[i])
- }
+ *out = new(HTTPSRecordSpec)
+ (*in).DeepCopyInto(*out)
+ }
+ if in.PTR != nil {
+ in, out := &in.PTR, &out.PTR
+ *out = new(PTRRecordSpec)
+ **out = **in
}
}
@@ -499,36 +725,31 @@ func (in *RecordEntry) DeepCopy() *RecordEntry {
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
-func (in *SRVRecordSpec) DeepCopyInto(out *SRVRecordSpec) {
+func (in *SOARecordSpec) DeepCopyInto(out *SOARecordSpec) {
*out = *in
}
-// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SRVRecordSpec.
-func (in *SRVRecordSpec) DeepCopy() *SRVRecordSpec {
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SOARecordSpec.
+func (in *SOARecordSpec) DeepCopy() *SOARecordSpec {
if in == nil {
return nil
}
- out := new(SRVRecordSpec)
+ out := new(SOARecordSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
-func (in *SimpleValues) DeepCopyInto(out *SimpleValues) {
+func (in *SRVRecordSpec) DeepCopyInto(out *SRVRecordSpec) {
*out = *in
- if in.Content != nil {
- in, out := &in.Content, &out.Content
- *out = make([]string, len(*in))
- copy(*out, *in)
- }
}
-// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SimpleValues.
-func (in *SimpleValues) DeepCopy() *SimpleValues {
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SRVRecordSpec.
+func (in *SRVRecordSpec) DeepCopy() *SRVRecordSpec {
if in == nil {
return nil
}
- out := new(SimpleValues)
+ out := new(SRVRecordSpec)
in.DeepCopyInto(out)
return out
}
@@ -568,6 +789,21 @@ func (in *TLSARecordSpec) DeepCopy() *TLSARecordSpec {
return out
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *TXTRecordSpec) DeepCopyInto(out *TXTRecordSpec) {
+ *out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TXTRecordSpec.
+func (in *TXTRecordSpec) DeepCopy() *TXTRecordSpec {
+ if in == nil {
+ return nil
+ }
+ out := new(TXTRecordSpec)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ZoneDefaults) DeepCopyInto(out *ZoneDefaults) {
*out = *in
diff --git a/cmd/main.go b/cmd/main.go
index 704577c..6fe4cd5 100644
--- a/cmd/main.go
+++ b/cmd/main.go
@@ -1,41 +1,43 @@
-/*
-Copyright 2025.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
+// SPDX-License-Identifier: AGPL-3.0-only
package main
import (
+ "context"
"crypto/tls"
+ "errors"
"flag"
+ "fmt"
"os"
+ "time"
// Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.)
// to ensure that exec-entrypoint and run can make use of them.
+ "golang.org/x/sync/errgroup"
_ "k8s.io/client-go/plugin/pkg/client/auth"
+ multiclusterproviders "go.miloapis.com/milo/pkg/multicluster-runtime"
+ milomulticluster "go.miloapis.com/milo/pkg/multicluster-runtime/milo"
"k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/runtime/serializer"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/cluster"
"sigs.k8s.io/controller-runtime/pkg/healthz"
"sigs.k8s.io/controller-runtime/pkg/log/zap"
+ "sigs.k8s.io/controller-runtime/pkg/manager"
"sigs.k8s.io/controller-runtime/pkg/metrics/filters"
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
"sigs.k8s.io/controller-runtime/pkg/webhook"
+ mcmanager "sigs.k8s.io/multicluster-runtime/pkg/manager"
+ "sigs.k8s.io/multicluster-runtime/pkg/multicluster"
+ mcsingle "sigs.k8s.io/multicluster-runtime/providers/single"
+ networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha"
dnsv1alpha1 "go.miloapis.com/dns-operator/api/v1alpha1"
+ "go.miloapis.com/dns-operator/internal/config"
"go.miloapis.com/dns-operator/internal/controller"
// +kubebuilder:scaffold:imports
)
@@ -43,11 +45,15 @@ import (
var (
scheme = runtime.NewScheme()
setupLog = ctrl.Log.WithName("setup")
+ codecs = serializer.NewCodecFactory(scheme, serializer.EnableStrict)
)
func init() {
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
+ utilruntime.Must(config.AddToScheme(scheme))
+ utilruntime.Must(config.RegisterDefaults(scheme))
utilruntime.Must(dnsv1alpha1.AddToScheme(scheme))
+ utilruntime.Must(networkingv1alpha.AddToScheme(scheme))
// +kubebuilder:scaffold:scheme
}
@@ -57,16 +63,29 @@ func main() {
var metricsCertPath, metricsCertName, metricsCertKey string
var webhookCertPath, webhookCertName, webhookCertKey string
var enableLeaderElection bool
+ var leaderElectionLeaseDuration time.Duration
+ var leaderElectionRenewDeadline time.Duration
+ var leaderElectionRetryPeriod time.Duration
var probeAddr string
var secureMetrics bool
var enableHTTP2 bool
var tlsOpts []func(*tls.Config)
+
+ var role string
+ var serverConfigFile string
+
flag.StringVar(&metricsAddr, "metrics-bind-address", "0", "The address the metrics endpoint binds to. "+
"Use :8443 for HTTPS or :8080 for HTTP, or leave as 0 to disable the metrics service.")
flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.")
flag.BoolVar(&enableLeaderElection, "leader-elect", false,
"Enable leader election for controller manager. "+
"Enabling this will ensure there is only one active controller manager.")
+ flag.DurationVar(&leaderElectionLeaseDuration, "leader-elect-lease-duration", 10*time.Second,
+ "The duration that non-leader candidates will wait to force acquire leadership.")
+ flag.DurationVar(&leaderElectionRenewDeadline, "leader-elect-renew-deadline", 3*time.Second,
+ "The duration that the leader will retry leadership renewal.")
+ flag.DurationVar(&leaderElectionRetryPeriod, "leader-elect-retry-period", 2*time.Second,
+ "The duration the clients should wait between attempting acquisition and renewal of a leadership.")
flag.BoolVar(&secureMetrics, "metrics-secure", true,
"If set, the metrics endpoint is served securely via HTTPS. Use --metrics-secure=false to use HTTP instead.")
flag.StringVar(&webhookCertPath, "webhook-cert-path", "", "The directory that contains the webhook certificate.")
@@ -78,6 +97,9 @@ func main() {
flag.StringVar(&metricsCertKey, "metrics-cert-key", "tls.key", "The name of the metrics server key file.")
flag.BoolVar(&enableHTTP2, "enable-http2", false,
"If set, HTTP/2 will be enabled for the metrics and webhook servers")
+ flag.StringVar(&serverConfigFile, "server-config", "", "path to the server config file")
+ flag.StringVar(&role, "role", "downstream", "Role for this binary: downstream|replicator|all")
+
opts := zap.Options{
Development: true,
}
@@ -86,6 +108,24 @@ func main() {
ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts)))
+ var serverConfig config.DNSOperator
+ var configData []byte
+ if len(serverConfigFile) > 0 {
+ var err error
+ configData, err = os.ReadFile(serverConfigFile)
+ if err != nil {
+ setupLog.Error(fmt.Errorf("unable to read server config from %q", serverConfigFile), "")
+ os.Exit(1)
+ }
+ }
+
+ if err := runtime.DecodeInto(codecs.UniversalDecoder(), configData, &serverConfig); err != nil {
+ setupLog.Error(err, "unable to decode server config")
+ os.Exit(1)
+ }
+
+ setupLog.Info("server config", "config", serverConfig)
+
// if the enable-http2 flag is false (the default), http/2 should be disabled
// due to its vulnerabilities. More specifically, disabling http/2 will
// prevent from being vulnerable to the HTTP/2 Stream Cancellation and
@@ -116,8 +156,6 @@ func main() {
webhookServerOptions.KeyName = webhookCertKey
}
- webhookServer := webhook.NewServer(webhookServerOptions)
-
// Metrics endpoint is enabled in 'config/default/kustomization.yaml'. The Metrics options configure the server.
// More info:
// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.22.1/pkg/metrics/server
@@ -153,58 +191,244 @@ func main() {
metricsServerOptions.KeyName = metricsCertKey
}
- mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
- Scheme: scheme,
- Metrics: metricsServerOptions,
- WebhookServer: webhookServer,
- HealthProbeBindAddress: probeAddr,
- LeaderElection: enableLeaderElection,
- LeaderElectionID: "bed97aed.networking.miloapis.com",
- // LeaderElectionReleaseOnCancel defines if the leader should step down voluntarily
- // when the Manager ends. This requires the binary to immediately end when the
- // Manager is stopped, otherwise, this setting is unsafe. Setting this significantly
- // speeds up voluntary leader transitions as the new leader don't have to wait
- // LeaseDuration time first.
- //
- // In the default scaffold provided, the program ends immediately after
- // the manager stops, so would be fine to enable this option. However,
- // if you are doing or is intended to do any operation such as perform cleanups
- // after the manager stops then its usage might be unsafe.
- // LeaderElectionReleaseOnCancel: true,
- })
- if err != nil {
- setupLog.Error(err, "unable to start manager")
- os.Exit(1)
- }
+ switch role {
+ case "downstream":
+ mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
+ Scheme: scheme,
+ Metrics: metricsServerOptions,
+ HealthProbeBindAddress: probeAddr,
+ LeaderElection: enableLeaderElection,
+ LeaseDuration: &leaderElectionLeaseDuration,
+ RenewDeadline: &leaderElectionRenewDeadline,
+ RetryPeriod: &leaderElectionRetryPeriod,
+ LeaderElectionID: "1813fe7c.datum.cloud",
+ })
+ if err != nil {
+ setupLog.Error(err, "unable to start manager")
+ os.Exit(1)
+ }
- if err := (&controller.DNSZoneReconciler{
- Client: mgr.GetClient(),
- Scheme: mgr.GetScheme(),
- }).SetupWithManager(mgr); err != nil {
- setupLog.Error(err, "unable to create controller", "controller", "DNSZone")
- os.Exit(1)
- }
- if err := (&controller.DNSRecordSetReconciler{
- Client: mgr.GetClient(),
- Scheme: mgr.GetScheme(),
- }).SetupWithManager(mgr); err != nil {
- setupLog.Error(err, "unable to create controller", "controller", "DNSRecordSet")
+ if err := (&controller.DNSZoneReconciler{Client: mgr.GetClient(),
+ Scheme: mgr.GetScheme()}).SetupWithManager(mgr); err != nil {
+ setupLog.Error(err, "unable to create controller", "controller", "DNSZone")
+ os.Exit(1)
+ }
+ if err := (&controller.DNSRecordSetReconciler{Client: mgr.GetClient(),
+ Scheme: mgr.GetScheme()}).SetupWithManager(mgr); err != nil {
+ setupLog.Error(err, "unable to create controller", "controller", "DNSRecordSet")
+ os.Exit(1)
+ }
+
+ if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
+ setupLog.Error(err, "unable to set up health check")
+ os.Exit(1)
+ }
+ if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil {
+ setupLog.Error(err, "unable to set up ready check")
+ os.Exit(1)
+ }
+
+ setupLog.Info("starting downstream manager")
+ if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
+ setupLog.Error(err, "problem running manager")
+ os.Exit(1)
+ }
+ return
+
+ case "replicator":
+ // Build downstream cluster from server config
+ downstreamRestConfig, err := serverConfig.DownstreamResourceManagement.RestConfig()
+ if err != nil {
+ setupLog.Error(err, "unable to load control plane kubeconfig")
+ os.Exit(1)
+ }
+ downstreamCluster, err := cluster.New(downstreamRestConfig, func(o *cluster.Options) { o.Scheme = scheme })
+ if err != nil {
+ setupLog.Error(err, "failed to construct downstream cluster")
+ os.Exit(1)
+ }
+
+ cfg := ctrl.GetConfigOrDie()
+ deploymentCluster, err := cluster.New(cfg, func(o *cluster.Options) { o.Scheme = scheme })
+ if err != nil {
+ setupLog.Error(err, "failed creating local cluster")
+ os.Exit(1)
+ }
+
+ // Initialize cluster discovery provider (single or milo)
+ runnables, provider, err := initializeClusterDiscovery(serverConfig, deploymentCluster, scheme)
+ if err != nil {
+ setupLog.Error(err, "unable to initialize cluster discovery")
+ os.Exit(1)
+ }
+
+ // Multicluster manager
+ mcmgr, err := mcmanager.New(cfg, provider, ctrl.Options{
+ Scheme: scheme,
+ Metrics: metricsServerOptions,
+ HealthProbeBindAddress: probeAddr,
+ LeaderElection: enableLeaderElection,
+ LeaderElectionID: "1813fe7c.datum.cloud",
+ })
+ if err != nil {
+ setupLog.Error(err, "unable to start multicluster manager")
+ os.Exit(1)
+ }
+
+ // --- register controllers BEFORE starting provider/manager ---
+ if err := (&controller.DNSRecordSetReplicator{
+ DownstreamClient: downstreamCluster.GetClient(),
+ }).SetupWithManager(mcmgr, downstreamCluster); err != nil {
+ setupLog.Error(err, "unable to create controller", "controller", "DNSRecordSetReplicator")
+ os.Exit(1)
+ }
+ if err := (&controller.DNSZoneReplicator{
+ DownstreamClient: downstreamCluster.GetClient(),
+ AccountingNamespace: serverConfig.DownstreamResourceManagement.DNSZoneAccountingNamespace,
+ }).SetupWithManager(mcmgr, downstreamCluster); err != nil {
+ setupLog.Error(err, "unable to create controller", "controller", "DNSZoneReplicator")
+ os.Exit(1)
+ }
+ if err := (&controller.DNSZoneDiscoveryReplicator{}).SetupWithManager(mcmgr); err != nil {
+ setupLog.Error(err, "unable to create controller", "controller", "DNSZoneDiscoveryReplicator")
+ os.Exit(1)
+ }
+
+ if err := mcmgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
+ setupLog.Error(err, "unable to set up health check")
+ os.Exit(1)
+ }
+ if err := mcmgr.AddReadyzCheck("readyz", healthz.Ping); err != nil {
+ setupLog.Error(err, "unable to set up ready check")
+ os.Exit(1)
+ }
+
+ // Start everything concurrently (no explicit wait-for-engagement loop)
+ ctx := ctrl.SetupSignalHandler()
+
+ g, ctx := errgroup.WithContext(ctx)
+
+ // Start any pre-created runnables (e.g., deploymentCluster)
+ for _, r := range runnables {
+ rr := r
+ g.Go(func() error { return ignoreCanceled(rr.Start(ctx)) })
+ }
+
+ // Start discovery provider (which will engage "single")
+ setupLog.Info("starting cluster discovery provider")
+ g.Go(func() error { return ignoreCanceled(provider.Run(ctx, mcmgr)) })
+
+ // Start downstream cluster (its cache backs your delegating client)
+ g.Go(func() error { return ignoreCanceled(downstreamCluster.Start(ctx)) })
+
+ // Finally start the multicluster manager (controllers + caches)
+ setupLog.Info("starting multicluster manager (replicator)")
+ g.Go(func() error { return ignoreCanceled(mcmgr.Start(ctx)) })
+
+ if err := g.Wait(); err != nil {
+ setupLog.Error(err, "problem running multicluster manager")
+ os.Exit(1)
+ }
+ return
+
+ default:
+ setupLog.Error(fmt.Errorf("invalid role: %s", role), "")
os.Exit(1)
}
- // +kubebuilder:scaffold:builder
+}
- if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
- setupLog.Error(err, "unable to set up health check")
- os.Exit(1)
+type runnableProvider interface {
+ multicluster.Provider
+ Run(context.Context, mcmanager.Manager) error
+}
+
+// Needed until we contribute the patch in the following PR again (need to sign CLA):
+//
+// See: https://github.com/kubernetes-sigs/multicluster-runtime/pull/18
+type wrappedSingleClusterProvider struct {
+ multicluster.Provider
+ cluster cluster.Cluster
+}
+
+func (p *wrappedSingleClusterProvider) Run(ctx context.Context, mgr mcmanager.Manager) error {
+ if err := mgr.Engage(ctx, "single", p.cluster); err != nil {
+ return err
}
- if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil {
- setupLog.Error(err, "unable to set up ready check")
- os.Exit(1)
+ return p.Provider.(runnableProvider).Run(ctx, mgr)
+}
+
+func initializeClusterDiscovery(
+ serverConfig config.DNSOperator,
+ deploymentCluster cluster.Cluster,
+ scheme *runtime.Scheme,
+) (runnables []manager.Runnable, provider runnableProvider, err error) {
+ runnables = append(runnables, deploymentCluster)
+ switch serverConfig.Discovery.Mode {
+ case multiclusterproviders.ProviderSingle:
+ provider = &wrappedSingleClusterProvider{
+ Provider: mcsingle.New("single", deploymentCluster),
+ cluster: deploymentCluster,
+ }
+
+ case multiclusterproviders.ProviderMilo:
+ discoveryRestConfig, err := serverConfig.Discovery.DiscoveryRestConfig()
+ if err != nil {
+ return nil, nil, fmt.Errorf("unable to get discovery rest config: %w", err)
+ }
+
+ projectRestConfig, err := serverConfig.Discovery.ProjectRestConfig()
+ if err != nil {
+ return nil, nil, fmt.Errorf("unable to get project rest config: %w", err)
+ }
+
+ discoveryManager, err := manager.New(discoveryRestConfig, manager.Options{
+ Client: client.Options{
+ Cache: &client.CacheOptions{
+ Unstructured: true,
+ },
+ },
+ })
+ if err != nil {
+ return nil, nil, fmt.Errorf("unable to set up overall controller manager: %w", err)
+ }
+
+ provider, err = milomulticluster.New(discoveryManager, milomulticluster.Options{
+ ClusterOptions: []cluster.Option{
+ func(o *cluster.Options) {
+ o.Scheme = scheme
+ },
+ },
+ InternalServiceDiscovery: serverConfig.Discovery.InternalServiceDiscovery,
+ ProjectRestConfig: projectRestConfig,
+ })
+ if err != nil {
+ return nil, nil, fmt.Errorf("unable to create datum project provider: %w", err)
+ }
+
+ runnables = append(runnables, discoveryManager)
+
+ // case providers.ProviderKind:
+ // provider = mckind.New(mckind.Options{
+ // ClusterOptions: []cluster.Option{
+ // func(o *cluster.Options) {
+ // o.Scheme = scheme
+ // },
+ // },
+ // })
+
+ default:
+ return nil, nil, fmt.Errorf(
+ "unsupported cluster discovery mode %s",
+ serverConfig.Discovery.Mode,
+ )
}
- setupLog.Info("starting manager")
- if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
- setupLog.Error(err, "problem running manager")
- os.Exit(1)
+ return runnables, provider, nil
+}
+
+func ignoreCanceled(err error) error {
+ if errors.Is(err, context.Canceled) {
+ return nil
}
+ return err
}
diff --git a/config/agent/kustomization.yaml b/config/agent/kustomization.yaml
new file mode 100644
index 0000000..08a0a34
--- /dev/null
+++ b/config/agent/kustomization.yaml
@@ -0,0 +1,28 @@
+namespace: dns-agent-system
+
+resources:
+- ../crd
+- ../rbac
+- namespace.yaml
+- manager.yaml
+- pdns-service.yaml
+- pdns-headless-service.yaml
+
+generatorOptions:
+ disableNameSuffixHash: true
+
+configMapGenerator:
+- name: agent-server-config
+ files:
+ - server-config.yaml
+- name: lightningstream-config
+ files:
+ - lightningstream.yaml
+- name: pdns-config
+ files:
+ - pdns.conf
+
+images:
+- name: ghcr.io/datum-cloud/dns-operator
+ newName: ghcr.io/datum-cloud/dns-operator
+ newTag: latest
diff --git a/config/agent/lightningstream.yaml b/config/agent/lightningstream.yaml
new file mode 100644
index 0000000..1e209a4
--- /dev/null
+++ b/config/agent/lightningstream.yaml
@@ -0,0 +1,51 @@
+instance: ${LS_INSTANCE}
+
+http:
+ address: ":8500"
+
+log:
+ level: info
+ format: human
+ timestamp: short
+
+lmdbs:
+ main:
+ path: /lmdb/db
+ options:
+ no_subdir: true
+ create: true
+ schema_tracks_changes: true
+ shard:
+ path: /lmdb/db-0
+ options:
+ no_subdir: true
+ create: true
+ schema_tracks_changes: true
+
+storage:
+ type: s3
+ options:
+ access_key: ${S3_ACCESS_KEY}
+ secret_key: ${S3_SECRET_KEY}
+ region: ${S3_REGION}
+ bucket: ${S3_BUCKET}
+ endpoint_url: ${S3_ENDPOINT_URL}
+ use_update_marker: true
+
+ cleanup:
+ # Enable the cleaner
+ enabled: true
+ # Interval to check if snapshots need to be cleaned. Some perturbation
+ # is added to this interval so that multiple instances started at exactly
+ # the same time do not always try to clean the same snapshots at the same
+ # time.
+ interval: 1h
+ # Snapshots must have been available for at least this interval before they
+ # are considered for cleaning, so that slower instances have a chance to
+ # download them.
+ must_keep_interval: 24h
+ # Remove stale instances without newer snapshots after this interval, but
+ # only after we are sure this instance has downloaded and merged that
+ # snapshot, and subsequently written a new snapshots that incorporates these
+ # changes.
+ remove_old_instances_interval: 168h # 1 week
diff --git a/config/agent/manager.yaml b/config/agent/manager.yaml
new file mode 100644
index 0000000..e949830
--- /dev/null
+++ b/config/agent/manager.yaml
@@ -0,0 +1,237 @@
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+ name: pdns-auth
+ namespace: system
+ labels:
+ control-plane: controller-agent
+ app.kubernetes.io/name: pdns-auth
+ app.kubernetes.io/managed-by: kustomize
+spec:
+ volumeClaimTemplates:
+ - metadata:
+ name: lmdb
+ spec:
+ accessModes: ["ReadWriteOnce"]
+ resources:
+ requests:
+ storage: 2Gi
+ serviceName: pdns-auth-headless
+ selector:
+ matchLabels:
+ control-plane: controller-agent
+ app.kubernetes.io/name: pdns-auth
+ replicas: 1
+ template:
+ metadata:
+ annotations:
+ kubectl.kubernetes.io/default-container: manager
+ labels:
+ control-plane: controller-agent
+ app.kubernetes.io/name: pdns-auth
+ spec:
+ securityContext:
+ fsGroup: 65532
+ seccompProfile:
+ type: RuntimeDefault
+ initContainers:
+ - name: init-pdns-key
+ image: busybox:1.36
+ command: ["/bin/sh","-c"]
+ args:
+ - |
+ set -euo pipefail
+ mkdir -p /run/pdns
+ umask 077
+ KEY=$(head -c32 /dev/urandom | od -An -tx1 | tr -d " \n")
+ printf "%s" "$KEY" > /run/pdns/api-key
+ chmod 0440 /run/pdns/api-key
+ volumeMounts:
+ - name: pdns-shared
+ mountPath: /run/pdns
+ securityContext:
+ runAsUser: 65532
+ - name: init-lmdb-perms
+ image: busybox:1.36
+ command: ["/bin/sh","-c"]
+ args:
+ - |
+ set -eu
+ mkdir -p /lmdb
+ chown -R 953:953 /lmdb
+ chmod 0775 /lmdb
+ if [ -f /run/pdns/api-key ]; then chmod 0444 /run/pdns/api-key; fi
+ securityContext:
+ runAsUser: 0
+ runAsGroup: 0
+ volumeMounts:
+ - name: lmdb
+ mountPath: /lmdb
+ - name: pdns-shared
+ mountPath: /run/pdns
+ containers:
+ - command:
+ - /manager
+ args:
+ - --role=downstream
+ - --leader-elect
+ - --leader-elect-lease-duration=10s
+ - --leader-elect-renew-deadline=3s
+ - --leader-elect-retry-period=2s
+ - --health-probe-bind-address=:8081
+ - --metrics-bind-address=:8080
+ - --metrics-secure=false
+ - --server-config=/config/server-config.yaml
+ env:
+ - name: PDNS_API_URL
+ value: http://localhost:8082
+ - name: PDNS_API_KEY_FILE
+ value: /run/pdns/api-key
+ image: ghcr.io/datum-cloud/dns-operator:latest
+ imagePullPolicy: IfNotPresent
+ name: manager
+ ports:
+ - containerPort: 8080
+ name: metrics
+ protocol: TCP
+ securityContext:
+ runAsNonRoot: true
+ readOnlyRootFilesystem: true
+ allowPrivilegeEscalation: false
+ capabilities:
+ drop:
+ - "ALL"
+ livenessProbe:
+ httpGet:
+ path: /healthz
+ port: 8081
+ initialDelaySeconds: 15
+ periodSeconds: 20
+ readinessProbe:
+ httpGet:
+ path: /readyz
+ port: 8081
+ initialDelaySeconds: 5
+ periodSeconds: 10
+ # TODO(user): Configure the resources accordingly based on the project requirements.
+ # More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/
+ resources:
+ limits:
+ cpu: 500m
+ memory: 128Mi
+ requests:
+ cpu: 10m
+ memory: 64Mi
+ volumeMounts:
+ - name: server-config
+ mountPath: /config
+ - name: pdns-shared
+ mountPath: /run/pdns
+ - name: pdns
+ image: powerdns/pdns-auth-51:latest
+ imagePullPolicy: IfNotPresent
+ ports:
+ - containerPort: 53
+ name: dns
+ protocol: UDP
+ - containerPort: 53
+ name: dns-tcp
+ protocol: TCP
+ - containerPort: 8082
+ name: api
+ protocol: TCP
+ volumeMounts:
+ - name: lmdb
+ mountPath: /lmdb
+ - name: pdns-shared
+ mountPath: /run/pdns
+ - name: pdns-config
+ mountPath: /etc/powerdns
+ readOnly: true
+ command: ["/bin/sh","-c"]
+ args:
+ - |
+ set -eu;
+ exec pdns_server \
+ --api-key="$(cat /run/pdns/api-key)" --api=yes --webserver-port=8082
+ securityContext:
+ runAsUser: 953
+ runAsGroup: 953
+ allowPrivilegeEscalation: false
+ readOnlyRootFilesystem: true
+ capabilities:
+ drop:
+ - "ALL"
+ add: ["NET_BIND_SERVICE"]
+ - name: lightningstream
+ image: powerdns/lightningstream:main
+ imagePullPolicy: IfNotPresent
+ command: ["lightningstream"]
+ ports:
+ - containerPort: 8500
+ name: lmdb-metrics
+ protocol: TCP
+ args: ["--config", "/etc/lightningstream/lightningstream.yaml", "--minimum-pid", "50", "sync"]
+ securityContext:
+ runAsUser: 953
+ runAsGroup: 953
+ allowPrivilegeEscalation: false
+ readOnlyRootFilesystem: true
+ capabilities:
+ drop:
+ - "ALL"
+ env:
+ - name: LS_INSTANCE
+ valueFrom:
+ fieldRef:
+ fieldPath: metadata.name
+ - name: S3_ACCESS_KEY
+ valueFrom:
+ secretKeyRef:
+ name: s3-credentials
+ key: accesskey
+ optional: true
+ - name: S3_SECRET_KEY
+ valueFrom:
+ secretKeyRef:
+ name: s3-credentials
+ key: secretkey
+ optional: true
+ - name: S3_ENDPOINT_URL
+ valueFrom:
+ secretKeyRef:
+ name: s3-credentials
+ key: endpoint_url
+ optional: true
+ - name: S3_BUCKET
+ valueFrom:
+ secretKeyRef:
+ name: s3-credentials
+ key: bucket
+ optional: true
+ - name: S3_REGION
+ valueFrom:
+ secretKeyRef:
+ name: s3-credentials
+ key: region
+ optional: true
+ volumeMounts:
+ - name: lmdb
+ mountPath: /lmdb
+ - name: lightningstream-config
+ mountPath: /etc/lightningstream
+
+ volumes:
+ - name: server-config
+ configMap:
+ name: agent-server-config
+ - name: pdns-shared
+ emptyDir: {}
+ - name: lightningstream-config
+ configMap:
+ name: lightningstream-config
+ - name: pdns-config
+ configMap:
+ name: pdns-config
+ serviceAccountName: controller-manager
+ terminationGracePeriodSeconds: 10
diff --git a/config/agent/namespace.yaml b/config/agent/namespace.yaml
new file mode 100644
index 0000000..c9c55cb
--- /dev/null
+++ b/config/agent/namespace.yaml
@@ -0,0 +1,7 @@
+apiVersion: v1
+kind: Namespace
+metadata:
+ name: dns-agent-system
+ labels:
+ app.kubernetes.io/name: pdns-auth
+ app.kubernetes.io/managed-by: kustomize
\ No newline at end of file
diff --git a/config/agent/pdns-headless-service.yaml b/config/agent/pdns-headless-service.yaml
new file mode 100644
index 0000000..e5fd7f6
--- /dev/null
+++ b/config/agent/pdns-headless-service.yaml
@@ -0,0 +1,10 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: pdns-auth-headless
+spec:
+ clusterIP: None
+ selector:
+ app.kubernetes.io/name: pdns-auth
+ ports: [] # no ports are needed for headless service
+
diff --git a/config/agent/pdns-service.yaml b/config/agent/pdns-service.yaml
new file mode 100644
index 0000000..cd1ff9b
--- /dev/null
+++ b/config/agent/pdns-service.yaml
@@ -0,0 +1,20 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: pdns-auth
+spec:
+ selector:
+ app.kubernetes.io/name: pdns-auth
+ ports:
+ - name: dns
+ port: 53
+ targetPort: 53
+ protocol: UDP
+ - name: dns-tcp
+ port: 53
+ targetPort: 53
+ protocol: TCP
+ - name: api
+ port: 8082
+ targetPort: 8082
+ protocol: TCP
diff --git a/config/agent/pdns.conf b/config/agent/pdns.conf
new file mode 100644
index 0000000..6bf5a10
--- /dev/null
+++ b/config/agent/pdns.conf
@@ -0,0 +1,36 @@
+setuid=pdns
+setgid=pdns
+guardian=no
+daemon=no
+disable-syslog=yes
+write-pid=no
+socket-dir=/run/pdns
+
+log-dns-queries=yes
+loglevel=99
+
+local-address=0.0.0.0,::
+local-port=53
+primary=yes
+secondary=yes
+
+webserver=yes
+webserver-address=0.0.0.0
+webserver-allow-from=0.0.0.0/0
+webserver-port=8082
+
+api=yes
+# api-key will be passed via CLI using /run/pdns/api-key
+
+zone-cache-refresh-interval=0
+zone-metadata-cache-ttl=0
+
+load-modules=liblmdbbackend.so
+launch=lmdb
+lmdb-filename=/lmdb/db
+lmdb-shards=1
+lmdb-random-ids=yes
+lmdb-flag-deleted=yes
+lmdb-map-size=1000
+lmdb-lightning-stream=yes
+
diff --git a/config/agent/server-config.yaml b/config/agent/server-config.yaml
new file mode 100644
index 0000000..be94f5f
--- /dev/null
+++ b/config/agent/server-config.yaml
@@ -0,0 +1,11 @@
+apiVersion: dns.networking.miloapis.com/v1alpha1
+kind: DNSOperator
+discovery:
+ mode: single
+ internalServiceDiscovery: false
+ discoveryKubeconfigPath: ""
+ projectKubeconfigPath: ""
+downstreamResourceManagement:
+ kubeconfigPath: ""
+
+
\ No newline at end of file
diff --git a/config/certmanager/certificate-metrics.yaml b/config/certmanager/certificate-metrics.yaml
new file mode 100644
index 0000000..2fb2380
--- /dev/null
+++ b/config/certmanager/certificate-metrics.yaml
@@ -0,0 +1,20 @@
+# The following manifests contain a self-signed issuer CR and a metrics certificate CR.
+# More document can be found at https://docs.cert-manager.io
+apiVersion: cert-manager.io/v1
+kind: Certificate
+metadata:
+ labels:
+ app.kubernetes.io/name: dns-operator
+ app.kubernetes.io/managed-by: kustomize
+ name: metrics-certs # this name should match the one appeared in kustomizeconfig.yaml
+ namespace: system
+spec:
+ dnsNames:
+ # SERVICE_NAME and SERVICE_NAMESPACE will be substituted by kustomize
+ # replacements in the config/default/kustomization.yaml file.
+ - SERVICE_NAME.SERVICE_NAMESPACE.svc
+ - SERVICE_NAME.SERVICE_NAMESPACE.svc.cluster.local
+ issuerRef:
+ kind: Issuer
+ name: selfsigned-issuer
+ secretName: metrics-server-cert
diff --git a/config/certmanager/certificate-webhook.yaml b/config/certmanager/certificate-webhook.yaml
new file mode 100644
index 0000000..99bf898
--- /dev/null
+++ b/config/certmanager/certificate-webhook.yaml
@@ -0,0 +1,20 @@
+# The following manifests contain a self-signed issuer CR and a certificate CR.
+# More document can be found at https://docs.cert-manager.io
+apiVersion: cert-manager.io/v1
+kind: Certificate
+metadata:
+ labels:
+ app.kubernetes.io/name: dns-operator
+ app.kubernetes.io/managed-by: kustomize
+ name: serving-cert # this name should match the one appeared in kustomizeconfig.yaml
+ namespace: system
+spec:
+ # SERVICE_NAME and SERVICE_NAMESPACE will be substituted by kustomize
+ # replacements in the config/default/kustomization.yaml file.
+ dnsNames:
+ - SERVICE_NAME.SERVICE_NAMESPACE.svc
+ - SERVICE_NAME.SERVICE_NAMESPACE.svc.cluster.local
+ issuerRef:
+ kind: Issuer
+ name: selfsigned-issuer
+ secretName: webhook-server-cert
diff --git a/config/certmanager/issuer.yaml b/config/certmanager/issuer.yaml
new file mode 100644
index 0000000..c527f42
--- /dev/null
+++ b/config/certmanager/issuer.yaml
@@ -0,0 +1,13 @@
+# The following manifest contains a self-signed issuer CR.
+# More information can be found at https://docs.cert-manager.io
+# WARNING: Targets CertManager v1.0. Check https://cert-manager.io/docs/installation/upgrading/ for breaking changes.
+apiVersion: cert-manager.io/v1
+kind: Issuer
+metadata:
+ labels:
+ app.kubernetes.io/name: dns-operator
+ app.kubernetes.io/managed-by: kustomize
+ name: selfsigned-issuer
+ namespace: system
+spec:
+ selfSigned: {}
diff --git a/config/certmanager/kustomization.yaml b/config/certmanager/kustomization.yaml
new file mode 100644
index 0000000..fcb7498
--- /dev/null
+++ b/config/certmanager/kustomization.yaml
@@ -0,0 +1,7 @@
+resources:
+- issuer.yaml
+- certificate-webhook.yaml
+- certificate-metrics.yaml
+
+configurations:
+- kustomizeconfig.yaml
diff --git a/config/certmanager/kustomizeconfig.yaml b/config/certmanager/kustomizeconfig.yaml
new file mode 100644
index 0000000..cf6f89e
--- /dev/null
+++ b/config/certmanager/kustomizeconfig.yaml
@@ -0,0 +1,8 @@
+# This configuration is for teaching kustomize how to update name ref substitution
+nameReference:
+- kind: Issuer
+ group: cert-manager.io
+ fieldSpecs:
+ - kind: Certificate
+ group: cert-manager.io
+ path: spec/issuerRef/name
diff --git a/config/crd/bases/dns.networking.miloapis.com_dnsrecordsets.yaml b/config/crd/bases/dns.networking.miloapis.com_dnsrecordsets.yaml
index 6773c28..2000afb 100644
--- a/config/crd/bases/dns.networking.miloapis.com_dnsrecordsets.yaml
+++ b/config/crd/bases/dns.networking.miloapis.com_dnsrecordsets.yaml
@@ -61,6 +61,9 @@ spec:
type: string
type: object
x-kubernetes-map-type: atomic
+ x-kubernetes-validations:
+ - message: dnsZoneRef.name must be set
+ rule: self.name != ''
recordType:
description: RecordType is the DNS RR type for this recordset.
enum:
@@ -89,135 +92,186 @@ spec:
should be set matching RecordType.
properties:
content:
- items:
- type: string
- type: array
+ format: ipv4
+ type: string
required:
- content
type: object
aaaa:
properties:
content:
- items:
- type: string
- type: array
+ format: ipv6
+ type: string
required:
- content
type: object
caa:
- items:
- properties:
- flag:
- type: integer
- tag:
- type: string
- value:
- type: string
- required:
- - flag
- - tag
- - value
- type: object
- type: array
+ properties:
+ flag:
+ description: 0–255 flag
+ maximum: 255
+ minimum: 0
+ type: integer
+ tag:
+ description: 'RFC-style tags: keep it simple: [a-z0-9]+'
+ minLength: 1
+ pattern: ^[a-z0-9]+$
+ type: string
+ value:
+ minLength: 1
+ type: string
+ required:
+ - flag
+ - tag
+ - value
+ type: object
cname:
properties:
content:
+ maxLength: 253
+ minLength: 1
+ pattern: ^([A-Za-z0-9_](?:[-A-Za-z0-9_]{0,61}[A-Za-z0-9_])?)(?:\.([A-Za-z0-9_](?:[-A-Za-z0-9_]{0,61}[A-Za-z0-9_])?))*\.?$
type: string
required:
- content
type: object
https:
- items:
- properties:
- params:
- additionalProperties:
- type: string
- type: object
- priority:
- type: integer
- target:
+ properties:
+ params:
+ additionalProperties:
type: string
- required:
- - priority
- - target
- type: object
- type: array
+ type: object
+ priority:
+ maximum: 65535
+ minimum: 0
+ type: integer
+ target:
+ type: string
+ required:
+ - priority
+ - target
+ type: object
mx:
- items:
- properties:
- exchange:
- type: string
- preference:
- type: integer
- required:
- - exchange
- - preference
- type: object
- type: array
+ properties:
+ exchange:
+ minLength: 1
+ type: string
+ preference:
+ maximum: 65535
+ minimum: 0
+ type: integer
+ required:
+ - exchange
+ - preference
+ type: object
name:
description: Name is the owner name (relative to the zone or
FQDN).
+ minLength: 1
+ pattern: ^(@|[A-Za-z0-9*._-]+)$
type: string
- raw:
- description: Raw contains raw RDATA strings when used instead
- of typed fields.
- items:
- type: string
- type: array
+ ns:
+ properties:
+ content:
+ description: |-
+ Require a hostname (FQDN or relative), allow optional trailing dot, no underscores.
+ Labels: 1-63 chars, alphanum with interior hyphens, total length <=253.
+ maxLength: 253
+ minLength: 1
+ pattern: ^([A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)(?:\.([A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?))*\.?$
+ type: string
+ required:
+ - content
+ type: object
+ ptr:
+ properties:
+ content:
+ type: string
+ required:
+ - content
+ type: object
+ soa:
+ properties:
+ expire:
+ format: int32
+ type: integer
+ mname:
+ minLength: 1
+ type: string
+ refresh:
+ format: int32
+ type: integer
+ retry:
+ format: int32
+ type: integer
+ rname:
+ minLength: 1
+ type: string
+ serial:
+ format: int32
+ type: integer
+ ttl:
+ format: int32
+ type: integer
+ required:
+ - mname
+ - rname
+ type: object
srv:
- items:
- properties:
- port:
- type: integer
- priority:
- type: integer
- target:
- type: string
- weight:
- type: integer
- required:
- - port
- - priority
- - target
- - weight
- type: object
- type: array
+ properties:
+ port:
+ maximum: 65535
+ minimum: 0
+ type: integer
+ priority:
+ maximum: 65535
+ minimum: 0
+ type: integer
+ target:
+ minLength: 1
+ type: string
+ weight:
+ maximum: 65535
+ minimum: 0
+ type: integer
+ required:
+ - port
+ - priority
+ - target
+ - weight
+ type: object
svcb:
- items:
- properties:
- params:
- additionalProperties:
- type: string
- type: object
- priority:
- type: integer
- target:
+ properties:
+ params:
+ additionalProperties:
type: string
- required:
- - priority
- - target
- type: object
- type: array
+ type: object
+ priority:
+ maximum: 65535
+ minimum: 0
+ type: integer
+ target:
+ type: string
+ required:
+ - priority
+ - target
+ type: object
tlsa:
- items:
- properties:
- certData:
- type: string
- matchingType:
- type: integer
- selector:
- type: integer
- usage:
- description: 'Common TLSA tuple: usage, selector, matching
- type, value'
- type: integer
- required:
- - certData
- - matchingType
- - selector
- - usage
- type: object
- type: array
+ properties:
+ certData:
+ type: string
+ matchingType:
+ type: integer
+ selector:
+ type: integer
+ usage:
+ type: integer
+ required:
+ - certData
+ - matchingType
+ - selector
+ - usage
+ type: object
ttl:
description: TTL optionally overrides TTL for this owner/RRset.
format: int64
@@ -225,15 +279,14 @@ spec:
txt:
properties:
content:
- items:
- type: string
- type: array
+ type: string
required:
- content
type: object
required:
- name
type: object
+ minItems: 1
type: array
required:
- dnsZoneRef
@@ -303,14 +356,13 @@ spec:
x-kubernetes-list-map-keys:
- type
x-kubernetes-list-type: map
- observedGeneration:
- description: ObservedGeneration is the last processed generation.
- format: int64
- type: integer
type: object
required:
- spec
type: object
+ selectableFields:
+ - jsonPath: .spec.dnsZoneRef.name
+ - jsonPath: .spec.recordType
served: true
storage: true
subresources:
diff --git a/config/crd/bases/dns.networking.miloapis.com_dnszoneclasses.yaml b/config/crd/bases/dns.networking.miloapis.com_dnszoneclasses.yaml
index 8aef219..ca58d7c 100644
--- a/config/crd/bases/dns.networking.miloapis.com_dnszoneclasses.yaml
+++ b/config/crd/bases/dns.networking.miloapis.com_dnszoneclasses.yaml
@@ -85,7 +85,6 @@ spec:
type: object
required:
- controllerName
- - nameServerPolicy
type: object
status:
description: status defines the observed state of DNSZoneClass
diff --git a/config/crd/bases/dns.networking.miloapis.com_dnszonediscoveries.yaml b/config/crd/bases/dns.networking.miloapis.com_dnszonediscoveries.yaml
new file mode 100644
index 0000000..543156d
--- /dev/null
+++ b/config/crd/bases/dns.networking.miloapis.com_dnszonediscoveries.yaml
@@ -0,0 +1,377 @@
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+ annotations:
+ controller-gen.kubebuilder.io/version: v0.19.0
+ name: dnszonediscoveries.dns.networking.miloapis.com
+spec:
+ group: dns.networking.miloapis.com
+ names:
+ kind: DNSZoneDiscovery
+ listKind: DNSZoneDiscoveryList
+ plural: dnszonediscoveries
+ shortNames:
+ - dnszd
+ singular: dnszonediscovery
+ scope: Namespaced
+ versions:
+ - additionalPrinterColumns:
+ - jsonPath: .status.conditions[?(@.type=="Accepted")].status
+ name: Accepted
+ type: string
+ - jsonPath: .status.conditions[?(@.type=="Discovered")].status
+ name: Discovered
+ type: string
+ name: v1alpha1
+ schema:
+ openAPIV3Schema:
+ description: DNSZoneDiscovery is the Schema for the DNSZone discovery API.
+ properties:
+ apiVersion:
+ description: |-
+ APIVersion defines the versioned schema of this representation of an object.
+ Servers should convert recognized schemas to the latest internal value, and
+ may reject unrecognized values.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
+ type: string
+ kind:
+ description: |-
+ Kind is a string value representing the REST resource this object represents.
+ Servers may infer this from the endpoint the client submits requests to.
+ Cannot be updated.
+ In CamelCase.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
+ type: string
+ metadata:
+ type: object
+ spec:
+ description: spec defines the desired target for discovery.
+ properties:
+ dnsZoneRef:
+ description: DNSZoneRef references the DNSZone (same namespace) this
+ discovery targets.
+ properties:
+ name:
+ default: ""
+ description: |-
+ Name of the referent.
+ This field is effectively required, but due to backwards compatibility is
+ allowed to be empty. Instances of this type with an empty value here are
+ almost certainly wrong.
+ More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
+ type: string
+ type: object
+ x-kubernetes-map-type: atomic
+ required:
+ - dnsZoneRef
+ type: object
+ status:
+ description: status contains the discovered data (write-once).
+ properties:
+ conditions:
+ description: Conditions includes Accepted and Discovered.
+ 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.
+ maxLength: 32768
+ 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
+ minimum: 0
+ 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.
+ maxLength: 1024
+ minLength: 1
+ pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
+ type: string
+ status:
+ description: status of the condition, one of True, False, Unknown.
+ enum:
+ - "True"
+ - "False"
+ - Unknown
+ type: string
+ type:
+ description: type of condition in CamelCase or in foo.example.com/CamelCase.
+ maxLength: 316
+ pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
+ type: string
+ required:
+ - lastTransitionTime
+ - message
+ - reason
+ - status
+ - type
+ type: object
+ type: array
+ x-kubernetes-list-map-keys:
+ - type
+ x-kubernetes-list-type: map
+ recordSets:
+ description: RecordSets is the set of discovered RRsets grouped by
+ RecordType.
+ items:
+ description: DiscoveredRecordSet groups discovered records by type.
+ properties:
+ recordType:
+ description: RecordType is the DNS RR type for this recordset.
+ enum:
+ - A
+ - AAAA
+ - CNAME
+ - TXT
+ - MX
+ - SRV
+ - CAA
+ - NS
+ - SOA
+ - PTR
+ - TLSA
+ - HTTPS
+ - SVCB
+ type: string
+ records:
+ description: |-
+ Records contains one or more owner names with values appropriate for the RecordType.
+ The RecordEntry schema is shared with DNSRecordSet for easy translation.
+ items:
+ description: RecordEntry represents one owner name and its
+ values.
+ properties:
+ a:
+ description: Exactly one of the following type-specific
+ fields should be set matching RecordType.
+ properties:
+ content:
+ format: ipv4
+ type: string
+ required:
+ - content
+ type: object
+ aaaa:
+ properties:
+ content:
+ format: ipv6
+ type: string
+ required:
+ - content
+ type: object
+ caa:
+ properties:
+ flag:
+ description: 0–255 flag
+ maximum: 255
+ minimum: 0
+ type: integer
+ tag:
+ description: 'RFC-style tags: keep it simple: [a-z0-9]+'
+ minLength: 1
+ pattern: ^[a-z0-9]+$
+ type: string
+ value:
+ minLength: 1
+ type: string
+ required:
+ - flag
+ - tag
+ - value
+ type: object
+ cname:
+ properties:
+ content:
+ maxLength: 253
+ minLength: 1
+ pattern: ^([A-Za-z0-9_](?:[-A-Za-z0-9_]{0,61}[A-Za-z0-9_])?)(?:\.([A-Za-z0-9_](?:[-A-Za-z0-9_]{0,61}[A-Za-z0-9_])?))*\.?$
+ type: string
+ required:
+ - content
+ type: object
+ https:
+ properties:
+ params:
+ additionalProperties:
+ type: string
+ type: object
+ priority:
+ maximum: 65535
+ minimum: 0
+ type: integer
+ target:
+ type: string
+ required:
+ - priority
+ - target
+ type: object
+ mx:
+ properties:
+ exchange:
+ minLength: 1
+ type: string
+ preference:
+ maximum: 65535
+ minimum: 0
+ type: integer
+ required:
+ - exchange
+ - preference
+ type: object
+ name:
+ description: Name is the owner name (relative to the zone
+ or FQDN).
+ minLength: 1
+ pattern: ^(@|[A-Za-z0-9*._-]+)$
+ type: string
+ ns:
+ properties:
+ content:
+ description: |-
+ Require a hostname (FQDN or relative), allow optional trailing dot, no underscores.
+ Labels: 1-63 chars, alphanum with interior hyphens, total length <=253.
+ maxLength: 253
+ minLength: 1
+ pattern: ^([A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)(?:\.([A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?))*\.?$
+ type: string
+ required:
+ - content
+ type: object
+ ptr:
+ properties:
+ content:
+ type: string
+ required:
+ - content
+ type: object
+ soa:
+ properties:
+ expire:
+ format: int32
+ type: integer
+ mname:
+ minLength: 1
+ type: string
+ refresh:
+ format: int32
+ type: integer
+ retry:
+ format: int32
+ type: integer
+ rname:
+ minLength: 1
+ type: string
+ serial:
+ format: int32
+ type: integer
+ ttl:
+ format: int32
+ type: integer
+ required:
+ - mname
+ - rname
+ type: object
+ srv:
+ properties:
+ port:
+ maximum: 65535
+ minimum: 0
+ type: integer
+ priority:
+ maximum: 65535
+ minimum: 0
+ type: integer
+ target:
+ minLength: 1
+ type: string
+ weight:
+ maximum: 65535
+ minimum: 0
+ type: integer
+ required:
+ - port
+ - priority
+ - target
+ - weight
+ type: object
+ svcb:
+ properties:
+ params:
+ additionalProperties:
+ type: string
+ type: object
+ priority:
+ maximum: 65535
+ minimum: 0
+ type: integer
+ target:
+ type: string
+ required:
+ - priority
+ - target
+ type: object
+ tlsa:
+ properties:
+ certData:
+ type: string
+ matchingType:
+ type: integer
+ selector:
+ type: integer
+ usage:
+ type: integer
+ required:
+ - certData
+ - matchingType
+ - selector
+ - usage
+ type: object
+ ttl:
+ description: TTL optionally overrides TTL for this owner/RRset.
+ format: int64
+ type: integer
+ txt:
+ properties:
+ content:
+ type: string
+ required:
+ - content
+ type: object
+ required:
+ - name
+ type: object
+ type: array
+ required:
+ - recordType
+ - records
+ type: object
+ type: array
+ type: object
+ required:
+ - spec
+ type: object
+ selectableFields:
+ - jsonPath: .spec.dnsZoneRef.name
+ served: true
+ storage: true
+ subresources:
+ status: {}
diff --git a/config/crd/bases/dns.networking.miloapis.com_dnszones.yaml b/config/crd/bases/dns.networking.miloapis.com_dnszones.yaml
index 15c9921..4e9bd77 100644
--- a/config/crd/bases/dns.networking.miloapis.com_dnszones.yaml
+++ b/config/crd/bases/dns.networking.miloapis.com_dnszones.yaml
@@ -21,6 +21,9 @@ spec:
- jsonPath: .status.conditions[?(@.type=="Programmed")].status
name: Programmed
type: string
+ - jsonPath: .status.recordCount
+ name: Records
+ type: integer
name: v1alpha1
schema:
openAPIV3Schema:
@@ -52,8 +55,18 @@ spec:
type: string
domainName:
description: DomainName is the FQDN of the zone (e.g., "example.com").
+ maxLength: 253
+ minLength: 1
+ pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
type: string
+ x-kubernetes-validations:
+ - message: A domain name is immutable and cannot be changed after
+ creation
+ rule: oldSelf == '' || self == oldSelf
+ - message: Must have at least two segments separated by dots
+ rule: self.indexOf('.') != -1
required:
+ - dnsZoneClassName
- domainName
type: object
status:
@@ -120,12 +133,49 @@ spec:
x-kubernetes-list-map-keys:
- type
x-kubernetes-list-type: map
+ domainRef:
+ description: DomainRef references the Domain this zone belongs to.
+ properties:
+ name:
+ type: string
+ status:
+ properties:
+ nameservers:
+ items:
+ properties:
+ hostname:
+ type: string
+ ips:
+ items:
+ description: NameserverIP captures per-address provenance
+ for a nameserver.
+ properties:
+ address:
+ type: string
+ registrantName:
+ type: string
+ required:
+ - address
+ type: object
+ type: array
+ required:
+ - hostname
+ type: object
+ type: array
+ type: object
+ required:
+ - name
+ type: object
nameservers:
description: Nameservers lists the active authoritative nameservers
for this zone.
items:
type: string
type: array
+ recordCount:
+ description: RecordCount is the number of DNSRecordSet resources in
+ this namespace that reference this zone.
+ type: integer
type: object
required:
- spec
diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml
index 4b85b6e..8fc3e9a 100644
--- a/config/crd/kustomization.yaml
+++ b/config/crd/kustomization.yaml
@@ -5,6 +5,7 @@ resources:
- bases/dns.networking.miloapis.com_dnszoneclasses.yaml
- bases/dns.networking.miloapis.com_dnszones.yaml
- bases/dns.networking.miloapis.com_dnsrecordsets.yaml
+- bases/dns.networking.miloapis.com_dnszonediscoveries.yaml
# +kubebuilder:scaffold:crdkustomizeresource
patches:
diff --git a/config/default/kustomization.yaml b/config/default/kustomization.yaml
index 52f3d3b..c4586c4 100644
--- a/config/default/kustomization.yaml
+++ b/config/default/kustomization.yaml
@@ -20,9 +20,9 @@ resources:
- ../manager
# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in
# crd/kustomization.yaml
-#- ../webhook
+# - ../webhook
# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 'WEBHOOK' components are required.
-#- ../certmanager
+- ../certmanager
# [PROMETHEUS] To enable prometheus monitor, uncomment all sections with 'PROMETHEUS'.
#- ../prometheus
# [METRICS] Expose the controller manager metrics service.
@@ -50,13 +50,13 @@ patches:
# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in
# crd/kustomization.yaml
-#- path: manager_webhook_patch.yaml
-# target:
-# kind: Deployment
+- path: manager_webhook_patch.yaml
+ target:
+ kind: Deployment
# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER' prefix.
# Uncomment the following replacements to add the cert-manager CA injection annotations
-#replacements:
+replacements:
# - source: # Uncomment the following block to enable certificates for metrics
# kind: Service
# version: v1
@@ -117,42 +117,42 @@ patches:
# index: 1
# create: true
-# - source: # Uncomment the following block if you have any webhook
-# kind: Service
-# version: v1
-# name: webhook-service
-# fieldPath: .metadata.name # Name of the service
-# targets:
-# - select:
-# kind: Certificate
-# group: cert-manager.io
-# version: v1
-# name: serving-cert
-# fieldPaths:
-# - .spec.dnsNames.0
-# - .spec.dnsNames.1
-# options:
-# delimiter: '.'
-# index: 0
-# create: true
-# - source:
-# kind: Service
-# version: v1
-# name: webhook-service
-# fieldPath: .metadata.namespace # Namespace of the service
-# targets:
-# - select:
-# kind: Certificate
-# group: cert-manager.io
-# version: v1
-# name: serving-cert
-# fieldPaths:
-# - .spec.dnsNames.0
-# - .spec.dnsNames.1
-# options:
-# delimiter: '.'
-# index: 1
-# create: true
+# - source: # Uncomment the following block if you have any webhook
+# kind: Service
+# version: v1
+# name: webhook-service
+# fieldPath: .metadata.name # Name of the service
+# targets:
+# - select:
+# kind: Certificate
+# group: cert-manager.io
+# version: v1
+# name: serving-cert
+# fieldPaths:
+# - .spec.dnsNames.0
+# - .spec.dnsNames.1
+# options:
+# delimiter: '.'
+# index: 0
+# create: true
+# - source:
+# kind: Service
+# version: v1
+# name: webhook-service
+# fieldPath: .metadata.namespace # Namespace of the service
+# targets:
+# - select:
+# kind: Certificate
+# group: cert-manager.io
+# version: v1
+# name: serving-cert
+# fieldPaths:
+# - .spec.dnsNames.0
+# - .spec.dnsNames.1
+# options:
+# delimiter: '.'
+# index: 1
+# create: true
# - source: # Uncomment the following block if you have a ValidatingWebhook (--programmatic-validation)
# kind: Certificate
@@ -185,36 +185,36 @@ patches:
# index: 1
# create: true
-# - source: # Uncomment the following block if you have a DefaultingWebhook (--defaulting )
-# kind: Certificate
-# group: cert-manager.io
-# version: v1
-# name: serving-cert
-# fieldPath: .metadata.namespace # Namespace of the certificate CR
-# targets:
-# - select:
-# kind: MutatingWebhookConfiguration
-# fieldPaths:
-# - .metadata.annotations.[cert-manager.io/inject-ca-from]
-# options:
-# delimiter: '/'
-# index: 0
-# create: true
-# - source:
-# kind: Certificate
-# group: cert-manager.io
-# version: v1
-# name: serving-cert
-# fieldPath: .metadata.name
-# targets:
-# - select:
-# kind: MutatingWebhookConfiguration
-# fieldPaths:
-# - .metadata.annotations.[cert-manager.io/inject-ca-from]
-# options:
-# delimiter: '/'
-# index: 1
-# create: true
+# - source: # Uncomment the following block if you have a DefaultingWebhook (--defaulting )
+# kind: Certificate
+# group: cert-manager.io
+# version: v1
+# name: serving-cert
+# fieldPath: .metadata.namespace # Namespace of the certificate CR
+# targets:
+# - select:
+# kind: MutatingWebhookConfiguration
+# fieldPaths:
+# - .metadata.annotations.[cert-manager.io/inject-ca-from]
+# options:
+# delimiter: '/'
+# index: 0
+# create: true
+# - source:
+# kind: Certificate
+# group: cert-manager.io
+# version: v1
+# name: serving-cert
+# fieldPath: .metadata.name
+# targets:
+# - select:
+# kind: MutatingWebhookConfiguration
+# fieldPaths:
+# - .metadata.annotations.[cert-manager.io/inject-ca-from]
+# options:
+# delimiter: '/'
+# index: 1
+# create: true
# - source: # Uncomment the following block if you have a ConversionWebhook (--conversion)
# kind: Certificate
diff --git a/config/default/manager_webhook_patch.yaml b/config/default/manager_webhook_patch.yaml
new file mode 100644
index 0000000..963c8a4
--- /dev/null
+++ b/config/default/manager_webhook_patch.yaml
@@ -0,0 +1,31 @@
+# This patch ensures the webhook certificates are properly mounted in the manager container.
+# It configures the necessary arguments, volumes, volume mounts, and container ports.
+
+# Add the --webhook-cert-path argument for configuring the webhook certificate path
+- op: add
+ path: /spec/template/spec/containers/0/args/-
+ value: --webhook-cert-path=/tmp/k8s-webhook-server/serving-certs
+
+# Add the volumeMount for the webhook certificates
+- op: add
+ path: /spec/template/spec/containers/0/volumeMounts/-
+ value:
+ mountPath: /tmp/k8s-webhook-server/serving-certs
+ name: webhook-certs
+ readOnly: true
+
+# Add the port configuration for the webhook server
+- op: add
+ path: /spec/template/spec/containers/0/ports/-
+ value:
+ containerPort: 9443
+ name: webhook-server
+ protocol: TCP
+
+# Add the volume configuration for the webhook certificates
+- op: add
+ path: /spec/template/spec/volumes/-
+ value:
+ name: webhook-certs
+ secret:
+ secretName: webhook-server-cert
diff --git a/config/iam/kustomization.yaml b/config/iam/kustomization.yaml
new file mode 100644
index 0000000..45c331a
--- /dev/null
+++ b/config/iam/kustomization.yaml
@@ -0,0 +1,12 @@
+# This kustomization program is used to create all of the Milo IAM resources to
+# configure the roles that are available to users and the resources protected by
+# the IAM system.
+#
+# This is created as a component so it can be included with other
+# kustomizations.
+apiVersion: kustomize.config.k8s.io/v1alpha1
+kind: Component
+
+resources:
+ - protected-resources/
+ - roles/
diff --git a/config/iam/protected-resources/dnsrecordsets.yaml b/config/iam/protected-resources/dnsrecordsets.yaml
new file mode 100644
index 0000000..f3d4cfb
--- /dev/null
+++ b/config/iam/protected-resources/dnsrecordsets.yaml
@@ -0,0 +1,24 @@
+---
+apiVersion: iam.miloapis.com/v1alpha1
+kind: ProtectedResource
+metadata:
+ name: dns.networking.miloapis.com-dnsrecordset
+spec:
+ serviceRef:
+ name: "dns.networking.miloapis.com"
+ kind: DNSRecordSet
+ plural: dnsrecordsets
+ singular: dnsrecordset
+ permissions:
+ - list
+ - get
+ - watch
+ - create
+ - update
+ - patch
+ - delete
+ parentResources:
+ - apiGroup: resourcemanager.miloapis.com
+ kind: Project
+
+
diff --git a/config/iam/protected-resources/dnszoneclasses.yaml b/config/iam/protected-resources/dnszoneclasses.yaml
new file mode 100644
index 0000000..20c1f54
--- /dev/null
+++ b/config/iam/protected-resources/dnszoneclasses.yaml
@@ -0,0 +1,20 @@
+---
+apiVersion: iam.miloapis.com/v1alpha1
+kind: ProtectedResource
+metadata:
+ name: dns.networking.miloapis.com-dnszoneclass
+spec:
+ serviceRef:
+ name: "dns.networking.miloapis.com"
+ kind: DNSZoneClass
+ plural: dnszoneclasses
+ singular: dnszoneclass
+ permissions:
+ - list
+ - get
+ - watch
+ parentResources:
+ - apiGroup: resourcemanager.miloapis.com
+ kind: Project
+
+
diff --git a/config/iam/protected-resources/dnszonediscoveries.yaml b/config/iam/protected-resources/dnszonediscoveries.yaml
new file mode 100644
index 0000000..9c273ff
--- /dev/null
+++ b/config/iam/protected-resources/dnszonediscoveries.yaml
@@ -0,0 +1,25 @@
+---
+apiVersion: iam.miloapis.com/v1alpha1
+kind: ProtectedResource
+metadata:
+ name: dns.networking.miloapis.com-dnszonediscovery
+spec:
+ serviceRef:
+ name: "dns.networking.miloapis.com"
+ kind: DNSZoneDiscovery
+ plural: dnszonediscoveries
+ singular: dnszonediscovery
+ permissions:
+ - list
+ - get
+ - watch
+ - create
+ - update
+ - patch
+ - delete
+ parentResources:
+ - apiGroup: resourcemanager.miloapis.com
+ kind: Project
+
+
+
diff --git a/config/iam/protected-resources/dnszones.yaml b/config/iam/protected-resources/dnszones.yaml
new file mode 100644
index 0000000..1aaf1ba
--- /dev/null
+++ b/config/iam/protected-resources/dnszones.yaml
@@ -0,0 +1,24 @@
+---
+apiVersion: iam.miloapis.com/v1alpha1
+kind: ProtectedResource
+metadata:
+ name: dns.networking.miloapis.com-dnszone
+spec:
+ serviceRef:
+ name: "dns.networking.miloapis.com"
+ kind: DNSZone
+ plural: dnszones
+ singular: dnszone
+ permissions:
+ - list
+ - get
+ - watch
+ - create
+ - update
+ - patch
+ - delete
+ parentResources:
+ - apiGroup: resourcemanager.miloapis.com
+ kind: Project
+
+
diff --git a/config/iam/protected-resources/kustomization.yaml b/config/iam/protected-resources/kustomization.yaml
new file mode 100644
index 0000000..2df1df9
--- /dev/null
+++ b/config/iam/protected-resources/kustomization.yaml
@@ -0,0 +1,12 @@
+# This kustomization program is used to create all of the Milo IAM protected
+# resources to configure the resources that are protected by the IAM system.
+#
+# Each Custom Resource Definition (CRD) exposed by the workload API that needs
+# to be protected by the IAM system should have a corresponding protected
+# resource configuration file in this directory.
+
+resources:
+ - dnszones.yaml
+ - dnsrecordsets.yaml
+ - dnszoneclasses.yaml
+ - dnszonediscoveries.yaml
diff --git a/config/iam/roles/dns-admin.yaml b/config/iam/roles/dns-admin.yaml
new file mode 100644
index 0000000..a01343f
--- /dev/null
+++ b/config/iam/roles/dns-admin.yaml
@@ -0,0 +1,26 @@
+apiVersion: iam.miloapis.com/v1alpha1
+kind: Role
+metadata:
+ name: dns.networking.miloapis.com-dns-admin
+ annotations:
+ kubernetes.io/display-name: DNS Admin
+ kubernetes.io/description: "Full access to DNS resources"
+spec:
+ launchStage: Beta
+ inheritedRoles:
+ - name: dns.networking.miloapis.com-dns-viewer
+ includedPermissions:
+ - dns.networking.miloapis.com/dnszones.create
+ - dns.networking.miloapis.com/dnszones.update
+ - dns.networking.miloapis.com/dnszones.patch
+ - dns.networking.miloapis.com/dnszones.delete
+ - dns.networking.miloapis.com/dnsrecordsets.create
+ - dns.networking.miloapis.com/dnsrecordsets.update
+ - dns.networking.miloapis.com/dnsrecordsets.patch
+ - dns.networking.miloapis.com/dnsrecordsets.delete
+ - dns.networking.miloapis.com/dnszonediscoveries.create
+ - dns.networking.miloapis.com/dnszonediscoveries.update
+ - dns.networking.miloapis.com/dnszonediscoveries.patch
+ - dns.networking.miloapis.com/dnszonediscoveries.delete
+
+
diff --git a/config/iam/roles/dns-viewer.yaml b/config/iam/roles/dns-viewer.yaml
new file mode 100644
index 0000000..97e2de4
--- /dev/null
+++ b/config/iam/roles/dns-viewer.yaml
@@ -0,0 +1,24 @@
+apiVersion: iam.miloapis.com/v1alpha1
+kind: Role
+metadata:
+ name: dns.networking.miloapis.com-dns-viewer
+ annotations:
+ kubernetes.io/display-name: DNS Viewer
+ kubernetes.io/description: "View access to DNS resources"
+spec:
+ launchStage: Beta
+ includedPermissions:
+ - dns.networking.miloapis.com/dnszones.list
+ - dns.networking.miloapis.com/dnszones.get
+ - dns.networking.miloapis.com/dnszones.watch
+ - dns.networking.miloapis.com/dnsrecordsets.list
+ - dns.networking.miloapis.com/dnsrecordsets.get
+ - dns.networking.miloapis.com/dnsrecordsets.watch
+ - dns.networking.miloapis.com/dnszoneclasses.list
+ - dns.networking.miloapis.com/dnszoneclasses.get
+ - dns.networking.miloapis.com/dnszoneclasses.watch
+ - dns.networking.miloapis.com/dnszonediscoveries.list
+ - dns.networking.miloapis.com/dnszonediscoveries.get
+ - dns.networking.miloapis.com/dnszonediscoveries.watch
+
+
diff --git a/config/iam/roles/kustomization.yaml b/config/iam/roles/kustomization.yaml
new file mode 100644
index 0000000..7745679
--- /dev/null
+++ b/config/iam/roles/kustomization.yaml
@@ -0,0 +1,8 @@
+# This kustomization program is used to create all of the Milo IAM roles that
+# are available to users.
+#
+# Each role should have a corresponding configuration file in this directory.
+
+resources:
+ - dns-viewer.yaml
+ - dns-admin.yaml
\ No newline at end of file
diff --git a/config/manager/kustomization.yaml b/config/manager/kustomization.yaml
index 5c5f0b8..c6f32c2 100644
--- a/config/manager/kustomization.yaml
+++ b/config/manager/kustomization.yaml
@@ -1,2 +1,15 @@
resources:
- manager.yaml
+
+generatorOptions:
+ disableNameSuffixHash: true
+
+configMapGenerator:
+- name: server-config
+ files:
+ - server-config.yaml
+
+images:
+- name: ghcr.io/datum-cloud/dns-operator
+ newName: ghcr.io/datum-cloud/dns-operator
+ newTag: latest
\ No newline at end of file
diff --git a/config/manager/manager.yaml b/config/manager/manager.yaml
index a20dd92..707206e 100644
--- a/config/manager/manager.yaml
+++ b/config/manager/manager.yaml
@@ -61,9 +61,12 @@ spec:
- command:
- /manager
args:
+ - --role=replicator
- --leader-elect
- --health-probe-bind-address=:8081
- image: controller:latest
+ - --server-config=/config/server-config.yaml
+ image: ghcr.io/datum-cloud/dns-operator:latest
+ imagePullPolicy: IfNotPresent
name: manager
ports: []
securityContext:
@@ -93,7 +96,12 @@ spec:
requests:
cpu: 10m
memory: 64Mi
- volumeMounts: []
- volumes: []
+ volumeMounts:
+ - name: server-config
+ mountPath: /config
+ volumes:
+ - name: server-config
+ configMap:
+ name: server-config
serviceAccountName: controller-manager
terminationGracePeriodSeconds: 10
diff --git a/config/manager/server-config.yaml b/config/manager/server-config.yaml
new file mode 100644
index 0000000..36074c9
--- /dev/null
+++ b/config/manager/server-config.yaml
@@ -0,0 +1,11 @@
+apiVersion: dns.networking.miloapis.com/v1alpha1
+kind: DNSOperator
+discovery:
+ mode: single
+ internalServiceDiscovery: false
+ discoveryKubeconfigPath: ""
+ projectKubeconfigPath: ""
+downstreamResourceManagement:
+ kubeconfigPath: "/downstream/kubeconfig"
+
+
\ No newline at end of file
diff --git a/config/network-policy/allow-metrics-traffic.yaml b/config/network-policy/allow-webhook-traffic.yaml
similarity index 56%
rename from config/network-policy/allow-metrics-traffic.yaml
rename to config/network-policy/allow-webhook-traffic.yaml
index 633da47..c678b99 100644
--- a/config/network-policy/allow-metrics-traffic.yaml
+++ b/config/network-policy/allow-webhook-traffic.yaml
@@ -1,13 +1,13 @@
-# This NetworkPolicy allows ingress traffic
-# with Pods running on namespaces labeled with 'metrics: enabled'. Only Pods on those
-# namespaces are able to gather data from the metrics endpoint.
+# This NetworkPolicy allows ingress traffic to your webhook server running
+# as part of the controller-manager from specific namespaces and pods. CR(s) which uses webhooks
+# will only work when applied in namespaces labeled with 'webhook: enabled'
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
labels:
app.kubernetes.io/name: dns-operator
app.kubernetes.io/managed-by: kustomize
- name: allow-metrics-traffic
+ name: allow-webhook-traffic
namespace: system
spec:
podSelector:
@@ -17,11 +17,11 @@ spec:
policyTypes:
- Ingress
ingress:
- # This allows ingress traffic from any namespace with the label metrics: enabled
+ # This allows ingress traffic from any namespace with the label webhook: enabled
- from:
- namespaceSelector:
matchLabels:
- metrics: enabled # Only from namespaces with this label
+ webhook: enabled # Only from namespaces with this label
ports:
- - port: 8443
+ - port: 443
protocol: TCP
diff --git a/config/network-policy/kustomization.yaml b/config/network-policy/kustomization.yaml
index ec0fb5e..a67bd68 100644
--- a/config/network-policy/kustomization.yaml
+++ b/config/network-policy/kustomization.yaml
@@ -1,2 +1,2 @@
resources:
-- allow-metrics-traffic.yaml
+- allow-webhook-traffic.yaml
diff --git a/config/overlays/agent-powerdns/kustomization.yaml b/config/overlays/agent-powerdns/kustomization.yaml
new file mode 100644
index 0000000..714027b
--- /dev/null
+++ b/config/overlays/agent-powerdns/kustomization.yaml
@@ -0,0 +1,30 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+
+namespace: dns-agent-system
+
+resources:
+ - ../../agent
+ - minio-deployment.yaml
+ - minio-service.yaml
+ - minio-pvc.yaml
+ - minio-credentials.yaml
+
+# Patch the replica count to 2
+patches:
+ - target:
+ kind: StatefulSet
+ name: pdns-auth
+ patch: |-
+ - op: replace
+ path: /spec/replicas
+ value: 2
+
+generatorOptions:
+ disableNameSuffixHash: true
+
+configMapGenerator:
+ - name: lightningstream-config
+ behavior: replace
+ files:
+ - lightningstream.yaml
\ No newline at end of file
diff --git a/config/overlays/agent-powerdns/lightningstream.yaml b/config/overlays/agent-powerdns/lightningstream.yaml
new file mode 100644
index 0000000..c4fd54e
--- /dev/null
+++ b/config/overlays/agent-powerdns/lightningstream.yaml
@@ -0,0 +1,50 @@
+instance: ${LS_INSTANCE}
+
+http:
+ address: ":8500"
+
+log:
+ level: info
+ format: human
+ timestamp: short
+
+lmdbs:
+ main:
+ path: /lmdb/db
+ options:
+ no_subdir: true
+ create: true
+ schema_tracks_changes: true
+ shard:
+ path: /lmdb/db-0
+ options:
+ no_subdir: true
+ create: true
+ schema_tracks_changes: true
+
+storage:
+ type: s3
+ options:
+ access_key: ${S3_ACCESS_KEY}
+ secret_key: ${S3_SECRET_KEY}
+ region: ${S3_REGION}
+ bucket: ${S3_BUCKET}
+ create_bucket: true
+ endpoint_url: ${S3_ENDPOINT_URL}
+ cleanup:
+ # Enable the cleaner
+ enabled: true
+ # Interval to check if snapshots need to be cleaned. Some perturbation
+ # is added to this interval so that multiple instances started at exactly
+ # the same time do not always try to clean the same snapshots at the same
+ # time.
+ interval: 1h
+ # Snapshots must have been available for at least this interval before they
+ # are considered for cleaning, so that slower instances have a chance to
+ # download them.
+ must_keep_interval: 24h
+ # Remove stale instances without newer snapshots after this interval, but
+ # only after we are sure this instance has downloaded and merged that
+ # snapshot, and subsequently written a new snapshots that incorporates these
+ # changes.
+ remove_old_instances_interval: 168h # 1 week
diff --git a/config/overlays/agent-powerdns/minio-credentials.yaml b/config/overlays/agent-powerdns/minio-credentials.yaml
new file mode 100644
index 0000000..11895b5
--- /dev/null
+++ b/config/overlays/agent-powerdns/minio-credentials.yaml
@@ -0,0 +1,12 @@
+apiVersion: v1
+kind: Secret
+metadata:
+ name: s3-credentials
+ namespace: dns-agent-system
+type: Opaque
+stringData:
+ accesskey: minioadmin
+ secretkey: minioadmin
+ endpoint_url: http://minio:9000
+ bucket: lightningstream
+ region: us-east-1
diff --git a/config/overlays/agent-powerdns/minio-deployment.yaml b/config/overlays/agent-powerdns/minio-deployment.yaml
new file mode 100644
index 0000000..6a47d49
--- /dev/null
+++ b/config/overlays/agent-powerdns/minio-deployment.yaml
@@ -0,0 +1,48 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: minio
+ namespace: dns-agent-system
+ labels:
+ app.kubernetes.io/name: minio
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app.kubernetes.io/name: minio
+ template:
+ metadata:
+ labels:
+ app.kubernetes.io/name: minio
+ spec:
+ containers:
+ - name: minio
+ image: minio/minio:latest
+ imagePullPolicy: IfNotPresent
+ args: ["server", "/data", "--console-address", ":9001"]
+ env:
+ - name: MINIO_ROOT_USER
+ valueFrom:
+ secretKeyRef:
+ name: minio-credentials
+ key: accesskey
+ optional: true
+ - name: MINIO_ROOT_PASSWORD
+ valueFrom:
+ secretKeyRef:
+ name: minio-credentials
+ key: secretkey
+ optional: true
+ ports:
+ - name: api
+ containerPort: 9000
+ - name: console
+ containerPort: 9001
+ volumeMounts:
+ - name: data
+ mountPath: /data
+ volumes:
+ - name: data
+ persistentVolumeClaim:
+ claimName: minio-data
+
diff --git a/config/overlays/agent-powerdns/minio-pvc.yaml b/config/overlays/agent-powerdns/minio-pvc.yaml
new file mode 100644
index 0000000..4709877
--- /dev/null
+++ b/config/overlays/agent-powerdns/minio-pvc.yaml
@@ -0,0 +1,12 @@
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: minio-data
+ namespace: dns-agent-system
+spec:
+ accessModes:
+ - ReadWriteOnce
+ resources:
+ requests:
+ storage: 10Gi
+
diff --git a/config/overlays/agent-powerdns/minio-service.yaml b/config/overlays/agent-powerdns/minio-service.yaml
new file mode 100644
index 0000000..9914732
--- /dev/null
+++ b/config/overlays/agent-powerdns/minio-service.yaml
@@ -0,0 +1,18 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: minio
+ namespace: dns-agent-system
+ labels:
+ app.kubernetes.io/name: minio
+spec:
+ selector:
+ app.kubernetes.io/name: minio
+ ports:
+ - name: api
+ port: 9000
+ targetPort: api
+ - name: console
+ port: 9001
+ targetPort: console
+
diff --git a/config/overlays/nso/config.yaml b/config/overlays/nso/config.yaml
new file mode 100644
index 0000000..a2b718f
--- /dev/null
+++ b/config/overlays/nso/config.yaml
@@ -0,0 +1,20 @@
+apiVersion: apiserver.config.datumapis.com/v1alpha1
+kind: NetworkServicesOperator
+metricsServer:
+ bindAddress: "0"
+webhookServer:
+ port: 9444
+ tls:
+ secretRef:
+ name: network-services-operator-webhook-server-cert
+ namespace: kube-system
+gateway:
+ targetDomain: prism.e2e.env.datum.net
+ downstreamGatewayClassName: datum-downstream-gateway-e2e
+ permittedTLSOptions:
+ gateway.networking.datumapis.com/certificate-issuer: []
+ listenerTLSOptions:
+ gateway.networking.datumapis.com/certificate-issuer: gateway-clusterissuer-selfsigned-ca
+downstreamResourceManagement:
+ kubeconfigPath: /config/kubeconfig
+
diff --git a/config/overlays/nso/configmap-rbac.yaml b/config/overlays/nso/configmap-rbac.yaml
new file mode 100644
index 0000000..ae3f8dc
--- /dev/null
+++ b/config/overlays/nso/configmap-rbac.yaml
@@ -0,0 +1,22 @@
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+ name: network-services-operator-configmaps-read
+rules:
+- apiGroups: [""]
+ resources: ["configmaps"]
+ verbs: ["get","list","watch"]
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRoleBinding
+metadata:
+ name: network-services-operator-configmaps-read
+subjects:
+- kind: ServiceAccount
+ name: network-services-operator-controller-manager
+ namespace: network-services-operator-system
+roleRef:
+ apiGroup: rbac.authorization.k8s.io
+ kind: ClusterRole
+ name: network-services-operator-configmaps-read
+
diff --git a/config/overlays/nso/domain-rbac.yaml b/config/overlays/nso/domain-rbac.yaml
new file mode 100644
index 0000000..0ed8cd2
--- /dev/null
+++ b/config/overlays/nso/domain-rbac.yaml
@@ -0,0 +1,25 @@
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+ name: network-services-operator-domains
+rules:
+- apiGroups: ["networking.datumapis.com"]
+ resources: ["domains"]
+ verbs: ["get","list","watch","create","update","patch","delete"]
+- apiGroups: ["networking.datumapis.com"]
+ resources: ["domains/status","domains/finalizers"]
+ verbs: ["get","update","patch"]
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRoleBinding
+metadata:
+ name: network-services-operator-domains
+subjects:
+- kind: ServiceAccount
+ name: network-services-operator-controller-manager
+ namespace: network-services-operator-system
+roleRef:
+ apiGroup: rbac.authorization.k8s.io
+ kind: ClusterRole
+ name: network-services-operator-domains
+
diff --git a/config/overlays/nso/kustomization.yaml b/config/overlays/nso/kustomization.yaml
new file mode 100644
index 0000000..7727e2f
--- /dev/null
+++ b/config/overlays/nso/kustomization.yaml
@@ -0,0 +1,23 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+
+namespace: network-services-operator-system
+
+resources:
+ - namespace.yaml
+ - leader-election-rbac.yaml
+ - domain-rbac.yaml
+ - configmap-rbac.yaml
+ - secret-rbac.yaml
+
+generatorOptions:
+ disableNameSuffixHash: true
+
+configMapGenerator:
+ - name: network-services-operator-config
+ files:
+ - config.yaml
+ # Use the upstream kubeconfig for downstreamResourceManagement.kubeconfigPath
+ - kubeconfig=../../../dev/kind.upstream.kubeconfig
+
+
diff --git a/config/overlays/nso/leader-election-rbac.yaml b/config/overlays/nso/leader-election-rbac.yaml
new file mode 100644
index 0000000..4e6aefc
--- /dev/null
+++ b/config/overlays/nso/leader-election-rbac.yaml
@@ -0,0 +1,27 @@
+apiVersion: rbac.authorization.k8s.io/v1
+kind: Role
+metadata:
+ name: nso-leader-election
+ namespace: network-services-operator-system
+rules:
+- apiGroups: ["coordination.k8s.io"]
+ resources: ["leases"]
+ verbs: ["get","list","watch","create","update","patch"]
+- apiGroups: [""]
+ resources: ["events"]
+ verbs: ["create","patch"]
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: RoleBinding
+metadata:
+ name: nso-leader-election
+ namespace: network-services-operator-system
+subjects:
+- kind: ServiceAccount
+ name: network-services-operator-controller-manager
+ namespace: network-services-operator-system
+roleRef:
+ kind: Role
+ name: nso-leader-election
+ apiGroup: rbac.authorization.k8s.io
+
diff --git a/config/overlays/nso/namespace.yaml b/config/overlays/nso/namespace.yaml
new file mode 100644
index 0000000..7a52ea2
--- /dev/null
+++ b/config/overlays/nso/namespace.yaml
@@ -0,0 +1,5 @@
+apiVersion: v1
+kind: Namespace
+metadata:
+ name: network-services-operator-system
+
diff --git a/config/overlays/nso/secret-rbac.yaml b/config/overlays/nso/secret-rbac.yaml
new file mode 100644
index 0000000..bbd2df5
--- /dev/null
+++ b/config/overlays/nso/secret-rbac.yaml
@@ -0,0 +1,22 @@
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+ name: network-services-operator-secrets-read
+rules:
+- apiGroups: [""]
+ resources: ["secrets"]
+ verbs: ["get","list","watch"]
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRoleBinding
+metadata:
+ name: network-services-operator-secrets-read
+subjects:
+- kind: ServiceAccount
+ name: network-services-operator-controller-manager
+ namespace: network-services-operator-system
+roleRef:
+ apiGroup: rbac.authorization.k8s.io
+ kind: ClusterRole
+ name: network-services-operator-secrets-read
+
diff --git a/config/overlays/replicator/kustomization.yaml b/config/overlays/replicator/kustomization.yaml
new file mode 100644
index 0000000..3a6ef56
--- /dev/null
+++ b/config/overlays/replicator/kustomization.yaml
@@ -0,0 +1,36 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+
+namespace: dns-replicator-system
+
+resources:
+ - ../../default
+
+patches:
+ # base now defaults to replicator; no extra manager patch needed
+ - path: patch-downstream-kubeconfig.yaml
+ target:
+ kind: Deployment
+ name: controller-manager
+ - path: patch-namespace.yaml
+ target:
+ kind: ServiceAccount
+ name: controller-manager
+ - path: patch-namespace.yaml
+ target:
+ kind: Service
+ name: controller-manager-metrics-service
+ - path: patch-namespace.yaml
+ target:
+ kind: ClusterRoleBinding
+ name: manager-rolebinding
+ - path: patch-namespace.yaml
+ target:
+ kind: ClusterRoleBinding
+ name: metrics-auth-rolebinding
+ - path: patch-namespace.yaml
+ target:
+ kind: RoleBinding
+ name: leader-election-rolebinding
+
+
diff --git a/config/overlays/replicator/patch-downstream-kubeconfig.yaml b/config/overlays/replicator/patch-downstream-kubeconfig.yaml
new file mode 100644
index 0000000..12f7405
--- /dev/null
+++ b/config/overlays/replicator/patch-downstream-kubeconfig.yaml
@@ -0,0 +1,16 @@
+- op: add
+ path: /spec/template/spec/volumes/-
+ value:
+ name: downstream-kubeconfig
+ secret:
+ secretName: downstream-kubeconfig
+- op: add
+ path: /spec/template/spec/containers/0/volumeMounts/-
+ value:
+ name: downstream-kubeconfig
+ mountPath: /downstream
+ readOnly: true
+
+
+
+
diff --git a/config/overlays/replicator/patch-mwc-ca-injection.yaml b/config/overlays/replicator/patch-mwc-ca-injection.yaml
new file mode 100644
index 0000000..40558f0
--- /dev/null
+++ b/config/overlays/replicator/patch-mwc-ca-injection.yaml
@@ -0,0 +1,6 @@
+- op: add
+ path: /metadata/annotations
+ value: {}
+- op: add
+ path: /metadata/annotations/cert-manager.io~1inject-ca-from
+ value: dns-replicator-system/dns-operator-serving-cert
diff --git a/config/overlays/replicator/patch-namespace-resource.yaml b/config/overlays/replicator/patch-namespace-resource.yaml
new file mode 100644
index 0000000..2dc68fd
--- /dev/null
+++ b/config/overlays/replicator/patch-namespace-resource.yaml
@@ -0,0 +1,7 @@
+- op: replace
+ path: /metadata/name
+ value: dns-replicator-system
+
+
+
+
diff --git a/config/overlays/replicator/patch-namespace.yaml b/config/overlays/replicator/patch-namespace.yaml
new file mode 100644
index 0000000..d6113e6
--- /dev/null
+++ b/config/overlays/replicator/patch-namespace.yaml
@@ -0,0 +1,7 @@
+- op: replace
+ path: /metadata/namespace
+ value: dns-replicator-system
+
+
+
+
diff --git a/config/rbac/dnszonediscovery_admin_role.yaml b/config/rbac/dnszonediscovery_admin_role.yaml
new file mode 100644
index 0000000..15b8be6
--- /dev/null
+++ b/config/rbac/dnszonediscovery_admin_role.yaml
@@ -0,0 +1,22 @@
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+ labels:
+ app.kubernetes.io/name: dns-operator
+ app.kubernetes.io/managed-by: kustomize
+ name: dnszonediscovery-admin-role
+rules:
+- apiGroups:
+ - dns.networking.miloapis.com
+ resources:
+ - dnszonediscoveries
+ verbs:
+ - '*'
+- apiGroups:
+ - dns.networking.miloapis.com
+ resources:
+ - dnszonediscoveries/status
+ verbs:
+ - get
+
+
diff --git a/config/rbac/dnszonediscovery_editor_role.yaml b/config/rbac/dnszonediscovery_editor_role.yaml
new file mode 100644
index 0000000..c09616a
--- /dev/null
+++ b/config/rbac/dnszonediscovery_editor_role.yaml
@@ -0,0 +1,28 @@
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+ labels:
+ app.kubernetes.io/name: dns-operator
+ app.kubernetes.io/managed-by: kustomize
+ name: dnszonediscovery-editor-role
+rules:
+- apiGroups:
+ - dns.networking.miloapis.com
+ resources:
+ - dnszonediscoveries
+ verbs:
+ - create
+ - delete
+ - get
+ - list
+ - patch
+ - update
+ - watch
+- apiGroups:
+ - dns.networking.miloapis.com
+ resources:
+ - dnszonediscoveries/status
+ verbs:
+ - get
+
+
diff --git a/config/rbac/dnszonediscovery_viewer_role.yaml b/config/rbac/dnszonediscovery_viewer_role.yaml
new file mode 100644
index 0000000..b4aa1b7
--- /dev/null
+++ b/config/rbac/dnszonediscovery_viewer_role.yaml
@@ -0,0 +1,24 @@
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+ labels:
+ app.kubernetes.io/name: dns-operator
+ app.kubernetes.io/managed-by: kustomize
+ name: dnszonediscovery-viewer-role
+rules:
+- apiGroups:
+ - dns.networking.miloapis.com
+ resources:
+ - dnszonediscoveries
+ verbs:
+ - get
+ - list
+ - watch
+- apiGroups:
+ - dns.networking.miloapis.com
+ resources:
+ - dnszonediscoveries/status
+ verbs:
+ - get
+
+
diff --git a/config/rbac/kustomization.yaml b/config/rbac/kustomization.yaml
index a45b23e..d5f4386 100644
--- a/config/rbac/kustomization.yaml
+++ b/config/rbac/kustomization.yaml
@@ -31,4 +31,7 @@ resources:
- dnszoneclass_admin_role.yaml
- dnszoneclass_editor_role.yaml
- dnszoneclass_viewer_role.yaml
+- dnszonediscovery_admin_role.yaml
+- dnszonediscovery_editor_role.yaml
+- dnszonediscovery_viewer_role.yaml
diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml
index 0bf2aa6..f138a63 100644
--- a/config/rbac/role.yaml
+++ b/config/rbac/role.yaml
@@ -4,6 +4,29 @@ kind: ClusterRole
metadata:
name: manager-role
rules:
+- apiGroups:
+ - ""
+ resources:
+ - configmaps
+ verbs:
+ - create
+ - delete
+ - get
+ - list
+ - patch
+ - update
+ - watch
+- apiGroups:
+ - ""
+ resources:
+ - namespaces
+ verbs:
+ - create
+ - get
+ - list
+ - patch
+ - update
+ - watch
- apiGroups:
- dns.networking.miloapis.com
resources:
@@ -21,15 +44,61 @@ rules:
- dns.networking.miloapis.com
resources:
- dnsrecordsets/finalizers
- - dnszones/finalizers
verbs:
- update
- apiGroups:
- dns.networking.miloapis.com
resources:
- dnsrecordsets/status
+ - dnszonediscoveries/status
- dnszones/status
verbs:
- get
- patch
- update
+- apiGroups:
+ - dns.networking.miloapis.com
+ resources:
+ - dnszoneclasses
+ verbs:
+ - get
+ - list
+ - watch
+- apiGroups:
+ - dns.networking.miloapis.com
+ resources:
+ - dnszonediscoveries
+ verbs:
+ - get
+ - list
+ - patch
+ - update
+ - watch
+- apiGroups:
+ - dns.networking.miloapis.com
+ resources:
+ - dnszones/finalizers
+ verbs:
+ - delete
+ - patch
+ - update
+- apiGroups:
+ - networking.datumapis.com
+ resources:
+ - domains
+ verbs:
+ - create
+ - delete
+ - get
+ - list
+ - patch
+ - update
+ - watch
+- apiGroups:
+ - networking.datumapis.com
+ resources:
+ - domains/status
+ verbs:
+ - get
+ - list
+ - watch
diff --git a/config/resource-metrics/dnsrecordsets.yaml b/config/resource-metrics/dnsrecordsets.yaml
new file mode 100644
index 0000000..547fd22
--- /dev/null
+++ b/config/resource-metrics/dnsrecordsets.yaml
@@ -0,0 +1,76 @@
+kind: CustomResourceStateMetrics
+spec:
+ resources:
+ - groupVersionKind:
+ group: dns.networking.miloapis.com
+ kind: "DNSRecordSet"
+ version: "v1alpha1"
+ metricNamePrefix: dns_record_set
+ labelsFromPath:
+ name: [metadata, name]
+ namespace: [metadata, namespace]
+ dns_zone_name: [spec, dnsZoneRef, name]
+ record_type: [spec, recordType]
+ metrics:
+ - name: "owner"
+ help: "Owner information"
+ errorLogV: 10
+ each:
+ type: Info
+ info:
+ path: [metadata,ownerReferences]
+ labelsFromPath:
+ owner_kind: [kind]
+ owner_name: [name]
+ owner_uid: [uid]
+ controller: [controller]
+ - name: "info"
+ help: "DNSRecordSet information"
+ each:
+ type: Info
+ info:
+ labelsFromPath:
+ uid: [metadata, uid]
+ - name: "record_info"
+ help: "DNSRecordSet record information"
+ each:
+ type: Info
+ info:
+ path: [spec, records]
+ labelsFromPath:
+ record_name: [name]
+ - name: "created"
+ help: "created timestamp"
+ each:
+ type: Gauge
+ gauge:
+ path: [metadata, creationTimestamp]
+ - name: "deleted"
+ help: "deletion timestamp"
+ errorLogV: 10
+ each:
+ type: Gauge
+ gauge:
+ path: [metadata, deletionTimestamp]
+ - name: "status_condition"
+ help: "The current status conditions of the Domains"
+ each:
+ type: Gauge
+ gauge:
+ path: [status, conditions]
+ labelsFromPath:
+ condition: [type]
+ reason: [reason]
+ status: [status]
+ valueFrom: [status]
+ - name: "status_condition_last_transition_time"
+ help: "last transition time for status conditions"
+ each:
+ type: Gauge
+ gauge:
+ path: [status, conditions]
+ labelsFromPath:
+ type: [type]
+ reason: [reason]
+ status: [status]
+ valueFrom: [lastTransitionTime]
diff --git a/config/resource-metrics/dnszones.yaml b/config/resource-metrics/dnszones.yaml
new file mode 100644
index 0000000..6a1b354
--- /dev/null
+++ b/config/resource-metrics/dnszones.yaml
@@ -0,0 +1,67 @@
+kind: CustomResourceStateMetrics
+spec:
+ resources:
+ - groupVersionKind:
+ group: dns.networking.miloapis.com
+ kind: "DNSZone"
+ version: "v1alpha1"
+ metricNamePrefix: dns_zone
+ labelsFromPath:
+ name: [metadata, name]
+ namespace: [metadata, namespace]
+ metrics:
+ - name: "owner"
+ help: "Owner information"
+ errorLogV: 10
+ each:
+ type: Info
+ info:
+ path: [metadata,ownerReferences]
+ labelsFromPath:
+ owner_kind: [kind]
+ owner_name: [name]
+ owner_uid: [uid]
+ controller: [controller]
+ - name: "info"
+ help: "DNSZone information"
+ each:
+ type: Info
+ info:
+ labelsFromPath:
+ uid: [metadata, uid]
+ domain_name: [spec, domainName]
+ - name: "created"
+ help: "created timestamp"
+ each:
+ type: Gauge
+ gauge:
+ path: [metadata, creationTimestamp]
+ - name: "deleted"
+ help: "deletion timestamp"
+ errorLogV: 10
+ each:
+ type: Gauge
+ gauge:
+ path: [metadata, deletionTimestamp]
+ - name: "status_condition"
+ help: "The current status conditions of the Domains"
+ each:
+ type: Gauge
+ gauge:
+ path: [status, conditions]
+ labelsFromPath:
+ condition: [type]
+ reason: [reason]
+ status: [status]
+ valueFrom: [status]
+ - name: "status_condition_last_transition_time"
+ help: "last transition time for status conditions"
+ each:
+ type: Gauge
+ gauge:
+ path: [status, conditions]
+ labelsFromPath:
+ type: [type]
+ reason: [reason]
+ status: [status]
+ valueFrom: [lastTransitionTime]
diff --git a/config/resource-metrics/kustomization.yaml b/config/resource-metrics/kustomization.yaml
new file mode 100644
index 0000000..c5c78ed
--- /dev/null
+++ b/config/resource-metrics/kustomization.yaml
@@ -0,0 +1,11 @@
+apiVersion: kustomize.config.k8s.io/v1alpha1
+kind: Component
+
+configMapGenerator:
+ - name: dns-operator-metrics
+ files:
+ - dnszones.yaml
+ - dnsrecordsets.yaml
+ options:
+ labels:
+ telemetry.miloapis.com/resource-metrics-config: "true"
diff --git a/config/samples/dns_v1alpha1_dnsrecordset.yaml b/config/samples/dns_v1alpha1_dnsrecordset.yaml
index 773b9ec..9f81a10 100644
--- a/config/samples/dns_v1alpha1_dnsrecordset.yaml
+++ b/config/samples/dns_v1alpha1_dnsrecordset.yaml
@@ -4,6 +4,14 @@ metadata:
labels:
app.kubernetes.io/name: dns-operator
app.kubernetes.io/managed-by: kustomize
- name: dnsrecordset-sample
+ name: www-example-com
spec:
- # TODO(user): Add fields here
+ dnsZoneRef:
+ name: example-com
+ recordType: A
+ records:
+ - name: www
+ ttl: 300
+ a:
+ content:
+ - 10.0.0.1
diff --git a/config/samples/dns_v1alpha1_dnszone.yaml b/config/samples/dns_v1alpha1_dnszone.yaml
index fa95c5e..ed7df53 100644
--- a/config/samples/dns_v1alpha1_dnszone.yaml
+++ b/config/samples/dns_v1alpha1_dnszone.yaml
@@ -4,6 +4,7 @@ metadata:
labels:
app.kubernetes.io/name: dns-operator
app.kubernetes.io/managed-by: kustomize
- name: dnszone-sample
+ name: example-com
spec:
- # TODO(user): Add fields here
+ domainName: example.com
+ dnsZoneClassName: powerdns-static
diff --git a/config/samples/dns_v1alpha1_dnszoneclass.yaml b/config/samples/dns_v1alpha1_dnszoneclass.yaml
index 85975e0..eb8e7ea 100644
--- a/config/samples/dns_v1alpha1_dnszoneclass.yaml
+++ b/config/samples/dns_v1alpha1_dnszoneclass.yaml
@@ -4,6 +4,14 @@ metadata:
labels:
app.kubernetes.io/name: dns-operator
app.kubernetes.io/managed-by: kustomize
- name: dnszoneclass-sample
+ name: powerdns-static
spec:
- # TODO(user): Add fields here
+ controllerName: powerdns
+ nameServerPolicy:
+ mode: Static
+ static:
+ servers:
+ - ns1.example.com
+ - ns2.example.com
+ defaults:
+ defaultTTL: 300
diff --git a/config/samples/dns_v1alpha1_dnszonediscovery.yaml b/config/samples/dns_v1alpha1_dnszonediscovery.yaml
new file mode 100644
index 0000000..9216ad8
--- /dev/null
+++ b/config/samples/dns_v1alpha1_dnszonediscovery.yaml
@@ -0,0 +1,10 @@
+apiVersion: dns.networking.miloapis.com/v1alpha1
+kind: DNSZoneDiscovery
+metadata:
+ labels:
+ app.kubernetes.io/name: dns-operator
+ app.kubernetes.io/managed-by: kustomize
+ name: www-example-com
+spec:
+ dnsZoneRef:
+ name: example-com
\ No newline at end of file
diff --git a/config/tools/cert-manager/kustomization.yaml b/config/tools/cert-manager/kustomization.yaml
new file mode 100644
index 0000000..79bcd45
--- /dev/null
+++ b/config/tools/cert-manager/kustomization.yaml
@@ -0,0 +1,20 @@
+resources:
+ - namespace.yaml
+helmCharts:
+ - name: cert-manager
+ namespace: cert-manager
+ valuesInline:
+ crds:
+ enabled: true
+ config:
+ apiVersion: controller.config.cert-manager.io/v1alpha1
+ kind: ControllerConfiguration
+ enableGatewayAPI: false
+ releaseName: cert-manager
+ version: 1.17.1
+ repo: https://charts.jetstack.io
+ - name: cert-manager-csi-driver
+ namespace: cert-manager
+ releaseName: cert-manager-csi-driver
+ version: v0.10.1
+ repo: https://charts.jetstack.io
diff --git a/config/tools/cert-manager/namespace.yaml b/config/tools/cert-manager/namespace.yaml
new file mode 100644
index 0000000..c90416f
--- /dev/null
+++ b/config/tools/cert-manager/namespace.yaml
@@ -0,0 +1,4 @@
+apiVersion: v1
+kind: Namespace
+metadata:
+ name: cert-manager
diff --git a/go.mod b/go.mod
index e4abc93..7508678 100644
--- a/go.mod
+++ b/go.mod
@@ -1,98 +1,159 @@
module go.miloapis.com/dns-operator
-go 1.24.5
+go 1.24.7
require (
- github.com/onsi/ginkgo/v2 v2.22.0
- github.com/onsi/gomega v1.36.1
- k8s.io/api v0.34.0
- k8s.io/apimachinery v0.34.0
- k8s.io/client-go v0.34.0
+ github.com/docker/docker v28.5.1+incompatible
+ github.com/miekg/dns v1.1.68
+ github.com/onsi/ginkgo/v2 v2.23.4
+ github.com/onsi/gomega v1.37.0
+ github.com/projectdiscovery/dnsx v1.2.1
+ go.datum.net/network-services-operator v0.9.0
+ go.miloapis.com/milo v0.7.4
+ golang.org/x/sync v0.17.0
+ k8s.io/api v0.34.1
+ k8s.io/apimachinery v0.34.1
+ k8s.io/client-go v0.34.1
sigs.k8s.io/controller-runtime v0.22.1
+ sigs.k8s.io/multicluster-runtime v0.21.0-alpha.8
+)
+
+require (
+ dario.cat/mergo v1.0.2 // indirect
+ github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
+ github.com/Microsoft/go-winio v0.6.2 // indirect
+ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
+ github.com/aymerick/douceur v0.2.0 // indirect
+ github.com/containerd/errdefs v1.0.0 // indirect
+ github.com/containerd/errdefs/pkg v0.3.0 // indirect
+ github.com/containerd/log v0.1.0 // indirect
+ github.com/containerd/platforms v0.2.1 // indirect
+ github.com/cpuguy83/dockercfg v0.3.2 // indirect
+ github.com/distribution/reference v0.6.0 // indirect
+ github.com/docker/go-connections v0.6.0 // indirect
+ github.com/docker/go-units v0.5.0 // indirect
+ github.com/ebitengine/purego v0.8.4 // indirect
+ github.com/go-ole/go-ole v1.3.0 // indirect
+ github.com/gorilla/css v1.0.0 // indirect
+ github.com/klauspost/compress v1.18.0 // indirect
+ github.com/lufia/plan9stats v0.0.0-20220913051719-115f729f3c8c // indirect
+ github.com/magiconair/properties v1.8.10 // indirect
+ github.com/microcosm-cc/bluemonday v1.0.25 // indirect
+ github.com/moby/docker-image-spec v1.3.1 // indirect
+ github.com/moby/go-archive v0.1.0 // indirect
+ github.com/moby/patternmatcher v0.6.0 // indirect
+ github.com/moby/sys/sequential v0.6.0 // indirect
+ github.com/moby/sys/user v0.4.0 // indirect
+ github.com/moby/sys/userns v0.1.0 // indirect
+ github.com/moby/term v0.5.2 // indirect
+ github.com/morikuni/aec v1.0.0 // indirect
+ github.com/opencontainers/go-digest v1.0.0 // indirect
+ github.com/opencontainers/image-spec v1.1.1 // indirect
+ github.com/power-devops/perfstat v0.0.0-20221212215047-62379fc7944b // indirect
+ github.com/projectdiscovery/blackrock v0.0.1 // indirect
+ github.com/projectdiscovery/cdncheck v1.0.9 // indirect
+ github.com/projectdiscovery/retryabledns v1.0.58 // indirect
+ github.com/projectdiscovery/utils v0.0.81 // indirect
+ github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // indirect
+ github.com/shirou/gopsutil/v4 v4.25.6 // indirect
+ github.com/sirupsen/logrus v1.9.3 // indirect
+ github.com/stretchr/testify v1.11.1 // indirect
+ github.com/tidwall/gjson v1.14.4 // indirect
+ github.com/tidwall/match v1.1.1 // indirect
+ github.com/tidwall/pretty v1.2.1 // indirect
+ github.com/tklauser/go-sysconf v0.3.12 // indirect
+ github.com/tklauser/numcpus v0.6.1 // indirect
+ github.com/weppos/publicsuffix-go v0.30.1-0.20230422193905-8fecedd899db // indirect
+ github.com/yl2chen/cidranger v1.0.2 // indirect
+ github.com/yusufpapurcu/wmi v1.2.4 // indirect
+ golang.org/x/crypto v0.43.0 // indirect
+ golang.org/x/mod v0.28.0 // indirect
)
require (
cel.dev/expr v0.24.0 // indirect
- github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
+ github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/blang/semver/v4 v4.0.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
- github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/emicklei/go-restful/v3 v3.12.2 // indirect
github.com/evanphx/json-patch/v5 v5.9.11 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
- github.com/go-logr/logr v1.4.2 // indirect
+ github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-logr/zapr v1.3.0 // indirect
- github.com/go-openapi/jsonpointer v0.21.0 // indirect
- github.com/go-openapi/jsonreference v0.20.2 // indirect
- github.com/go-openapi/swag v0.23.0 // indirect
+ github.com/go-openapi/jsonpointer v0.21.1 // indirect
+ github.com/go-openapi/jsonreference v0.21.0 // indirect
+ github.com/go-openapi/swag v0.23.1 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/cel-go v0.26.0 // indirect
github.com/google/gnostic-models v0.7.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
- github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db // indirect
+ github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect
github.com/google/uuid v1.6.0 // indirect
- github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect
+ github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
- github.com/mailru/easyjson v0.7.7 // indirect
+ github.com/mailru/easyjson v0.9.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pkg/errors v0.9.1 // indirect
- github.com/pmezard/go-difflib v1.0.0 // indirect
- github.com/prometheus/client_golang v1.22.0 // indirect
- github.com/prometheus/client_model v0.6.1 // indirect
- github.com/prometheus/common v0.62.0 // indirect
- github.com/prometheus/procfs v0.15.1 // indirect
+ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
+ github.com/prometheus/client_golang v1.23.0 // indirect
+ github.com/prometheus/client_model v0.6.2 // indirect
+ github.com/prometheus/common v0.65.0 // indirect
+ github.com/prometheus/procfs v0.17.0 // indirect
github.com/spf13/cobra v1.9.1 // indirect
- github.com/spf13/pflag v1.0.6 // indirect
- github.com/stoewer/go-strcase v1.3.0 // indirect
+ github.com/spf13/pflag v1.0.7 // indirect
+ github.com/stoewer/go-strcase v1.3.1 // indirect
+ github.com/testcontainers/testcontainers-go v0.40.0
github.com/x448/float16 v0.8.4 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
- go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect
- go.opentelemetry.io/otel v1.35.0 // indirect
- go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 // indirect
- go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 // indirect
- go.opentelemetry.io/otel/metric v1.35.0 // indirect
- go.opentelemetry.io/otel/sdk v1.34.0 // indirect
- go.opentelemetry.io/otel/trace v1.35.0 // indirect
- go.opentelemetry.io/proto/otlp v1.5.0 // indirect
+ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect
+ go.opentelemetry.io/otel v1.37.0 // indirect
+ go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect
+ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 // indirect
+ go.opentelemetry.io/otel/metric v1.37.0 // indirect
+ go.opentelemetry.io/otel/sdk v1.37.0 // indirect
+ go.opentelemetry.io/otel/trace v1.37.0 // indirect
+ go.opentelemetry.io/proto/otlp v1.7.1 // indirect
+ go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
- golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
- golang.org/x/net v0.38.0 // indirect
- golang.org/x/oauth2 v0.27.0 // indirect
- golang.org/x/sync v0.12.0 // indirect
- golang.org/x/sys v0.31.0 // indirect
- golang.org/x/term v0.30.0 // indirect
- golang.org/x/text v0.23.0 // indirect
- golang.org/x/time v0.9.0 // indirect
- golang.org/x/tools v0.26.0 // indirect
- gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
- google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect
- google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb // indirect
- google.golang.org/grpc v1.72.1 // indirect
- google.golang.org/protobuf v1.36.5 // indirect
+ golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 // indirect
+ golang.org/x/net v0.45.0 // indirect
+ golang.org/x/oauth2 v0.30.0 // indirect
+ golang.org/x/sys v0.37.0 // indirect
+ golang.org/x/term v0.36.0 // indirect
+ golang.org/x/text v0.30.0 // indirect
+ golang.org/x/time v0.12.0 // indirect
+ golang.org/x/tools v0.37.0 // indirect
+ gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect
+ google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 // indirect
+ google.golang.org/grpc v1.74.2 // indirect
+ google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
- k8s.io/apiextensions-apiserver v0.34.0 // indirect
- k8s.io/apiserver v0.34.0 // indirect
- k8s.io/component-base v0.34.0 // indirect
+ k8s.io/apiextensions-apiserver v0.34.1 // indirect
+ k8s.io/apiserver v0.34.1 // indirect
+ k8s.io/component-base v0.34.1 // indirect
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect
sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 // indirect
+ sigs.k8s.io/gateway-api v1.3.1-0.20250527223622-54df0a899c1c // indirect
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect
diff --git a/go.sum b/go.sum
index a603486..2661c4d 100644
--- a/go.sum
+++ b/go.sum
@@ -1,7 +1,20 @@
cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY=
cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
-github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI=
-github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g=
+cloud.google.com/go/compute/metadata v0.2.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
+dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
+dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
+github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
+github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
+github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
+github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
+github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
+github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
+github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
+github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
+github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
+github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
+github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
+github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM=
@@ -10,15 +23,37 @@ github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK3
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
+github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
+github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
+github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
+github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
+github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
+github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
+github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
+github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
+github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
-github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
+github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
+github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
+github.com/docker/docker v28.5.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM=
+github.com/docker/docker v28.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
+github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
+github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
+github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
+github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
+github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
+github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU=
github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
-github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k=
-github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ=
+github.com/evanphx/json-patch v5.9.11+incompatible h1:ixHHqfcGvxhWkniF1tWxBHA0yb4Z+d1UQi45df52xW8=
+github.com/evanphx/json-patch v5.9.11+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU=
github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
@@ -28,24 +63,29 @@ github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
-github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
-github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ=
github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg=
-github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
-github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
-github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
-github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
-github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
-github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
-github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
-github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
+github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
+github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
+github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
+github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic=
+github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk=
+github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
+github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4=
+github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU=
+github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
+github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
+github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
@@ -54,17 +94,25 @@ github.com/google/cel-go v0.26.0 h1:DPGjXackMpJWH680oGY4lZhYjIameYmR+/6RBdDGmaI=
github.com/google/cel-go v0.26.0/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM=
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
+github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/go-github/v50 v50.1.0/go.mod h1:Ev4Tre8QoKiolvbpOSG3FIi4Mlon3S2Nt9W5JYqKiwA=
+github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
-github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo=
-github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
+github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
+github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
-github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI=
+github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
+github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
@@ -75,85 +123,164 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
-github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
-github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
-github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
-github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
-github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
+github.com/lufia/plan9stats v0.0.0-20220913051719-115f729f3c8c h1:VtwQ41oftZwlMnOEbMWQtSEUgU64U4s+GHk7hZK+jtY=
+github.com/lufia/plan9stats v0.0.0-20220913051719-115f729f3c8c/go.mod h1:JKx41uQRwqlTZabZc+kILPrO/3jlKnQ2Z8b7YiVw5cE=
+github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
+github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
+github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
+github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
+github.com/microcosm-cc/bluemonday v1.0.25 h1:4NEwSfiJ+Wva0VxN5B8OwMicaJvD8r9tlJWm9rtloEg=
+github.com/microcosm-cc/bluemonday v1.0.25/go.mod h1:ZIOjCQp1OrzBBPIJmfX4qDYFuhU02nx4bn030ixfHLE=
+github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA=
+github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps=
+github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
+github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
+github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ=
+github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo=
+github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
+github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
+github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
+github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
+github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
+github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
+github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
+github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
+github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
+github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
+github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
+github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
+github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
-github.com/onsi/ginkgo/v2 v2.22.0 h1:Yed107/8DjTr0lKCNt7Dn8yQ6ybuDRQoMGrNFKzMfHg=
-github.com/onsi/ginkgo/v2 v2.22.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
-github.com/onsi/gomega v1.36.1 h1:bJDPBO7ibjxcbHMgSCoo4Yj18UWbKDlLwX1x9sybDcw=
-github.com/onsi/gomega v1.36.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
+github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=
+github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=
+github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y=
+github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
+github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
+github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
+github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
+github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
-github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
-github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
-github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
-github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
-github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
-github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
-github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/power-devops/perfstat v0.0.0-20221212215047-62379fc7944b h1:0LFwY6Q3gMACTjAbMZBjXAqTOzOwFaj2Ld6cjeQ7Rig=
+github.com/power-devops/perfstat v0.0.0-20221212215047-62379fc7944b/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
+github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
+github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
+github.com/projectdiscovery/blackrock v0.0.1 h1:lHQqhaaEFjgf5WkuItbpeCZv2DUIE45k0VbGJyft6LQ=
+github.com/projectdiscovery/blackrock v0.0.1/go.mod h1:ANUtjDfaVrqB453bzToU+YB4cUbvBRpLvEwoWIwlTss=
+github.com/projectdiscovery/cdncheck v1.0.9 h1:BS15gzj9gb5AVSKqTDzPamfSgStu7nJQOocUvrssFlg=
+github.com/projectdiscovery/cdncheck v1.0.9/go.mod h1:18SSl1w7rMj53CGeRIZTbDoa286a6xZIxGbaiEo4Fxs=
+github.com/projectdiscovery/dnsx v1.2.1 h1:TxslYvp1Z/YZ4CP/J0gx5RYpvXREnVmyoacmTcGu5yg=
+github.com/projectdiscovery/dnsx v1.2.1/go.mod h1:6dAsMCEDu7FArZy2qjyTeUQrqpZ4ITLU11fcmUvFqt0=
+github.com/projectdiscovery/retryabledns v1.0.58 h1:ut1FSB9+GZ6zQIlKJFLqIz2RZs81EmkbsHTuIrWfYLE=
+github.com/projectdiscovery/retryabledns v1.0.58/go.mod h1:RobmKoNBgngAVE4H9REQtaLP1pa4TCyypHy1MWHT1mY=
+github.com/projectdiscovery/utils v0.0.81 h1:Cqz6uFncCKWRLqpVHWlnHXaRE3whzH32yZJa/1zOEzU=
+github.com/projectdiscovery/utils v0.0.81/go.mod h1:pTGvF08EXa07e2OM+tu8IcnxTeAT34bzAhSW/Efcens=
+github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc=
+github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE=
+github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
+github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
+github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
+github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
+github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
+github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d h1:hrujxIzL1woJ7AwssoOcM/tq5JjjG2yYOc8odClEiXA=
+github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU=
+github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs=
+github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c=
+github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
+github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
-github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
-github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs=
-github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo=
+github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M=
+github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/stoewer/go-strcase v1.3.1 h1:iS0MdW+kVTxgMoE1LAZyMiYJFKlOzLooE4MxjirtkAs=
+github.com/stoewer/go-strcase v1.3.1/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
-github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
-github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU=
+github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY=
+github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=
+github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
+github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
+github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
+github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
+github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
+github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
+github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
+github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
+github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
+github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
+github.com/weppos/publicsuffix-go v0.30.1-0.20230422193905-8fecedd899db h1:/WcxBne+5CbtbgWd/sV2wbravmr4sT7y52ifQaCgoLs=
+github.com/weppos/publicsuffix-go v0.30.1-0.20230422193905-8fecedd899db/go.mod h1:aiQaH1XpzIfgrJq3S1iw7w+3EDbRP7mF5fmwUhWyRUs=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
+github.com/yl2chen/cidranger v1.0.2 h1:lbOWZVCG1tCRX4u24kuM1Tb4nHqWkDxwLdoS+SevawU=
+github.com/yl2chen/cidranger v1.0.2/go.mod h1:9U1yz7WPYDwf0vpNWFaeRh0bjwz5RVgRy/9UEQfHl0g=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
+github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
+go.datum.net/network-services-operator v0.9.0 h1:D+q4iXFlQTeZxPJBxPW7hDJW9sHLMAiVhUjjJKT2R98=
+go.datum.net/network-services-operator v0.9.0/go.mod h1:RvP9wWGKSlWlASp1pe/vgvaExm+BsD6hq9XJzElN6dY=
+go.miloapis.com/milo v0.7.4 h1:fpFtWjItNvFj7rSsHbFmaEaBM/01QzlS5jYBF9DWweQ=
+go.miloapis.com/milo v0.7.4/go.mod h1:rmI6r0kL/pRaAkSlDbOVj/LQB3QgEAmDG5dwA1i7rP4=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q=
-go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
-go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 h1:OeNbIYk/2C15ckl7glBlOBp5+WlYsOElzTNmiPW/x60=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 h1:tgJ0uaNS4c98WRNUEx5U3aDlrDOI5Rs+1Vifcw4DJ8U=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0/go.mod h1:U7HYyW0zt/a9x5J1Kjs+r1f/d4ZHnYFclhYY2+YbeoE=
-go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
-go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
-go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A=
-go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU=
-go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk=
-go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w=
-go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
-go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
-go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4=
-go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY=
+go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
+go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 h1:m639+BofXTvcY1q8CGs4ItwQarYtJPOWmVobfM1HpVI=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0/go.mod h1:LjReUci/F4BUyv+y4dwnq3h/26iNOeC3wAIqgvTIZVo=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU=
+go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
+go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
+go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
+go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
+go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
+go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
+go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
+go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
+go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4=
+go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE=
+go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
+go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
@@ -167,56 +294,102 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
-golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
+golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
+golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
+golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
+golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 h1:R9PFI6EUdfVKgwKjZef7QIwGcBKu86OEFpJ9nUEP2l4=
+golang.org/x/exp v0.0.0-20250718183923-645b1fa84792/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
+golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
-golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
-golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
-golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M=
-golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
+golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
+golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
+golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
+golang.org/x/oauth2 v0.5.0/go.mod h1:9/XBHVqLaWO3/BRHs5jbpYCnOZVjj5V0ndyaAM7KB4I=
+golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
+golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
-golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
+golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
-golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
-golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
-golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
+golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
+golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
+golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
-golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
-golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
-golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
+golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
+golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
+golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
-golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
-golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
+golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
+golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw=
-gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY=
-google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb h1:p31xT4yrYrSM/G4Sn2+TNUkVhFCbG9y8itM2S6Th950=
-google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:jbe3Bkdp+Dh2IrslsFCklNhweNTBgSYanP1UXhJDhKg=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb h1:TLPQVbx1GJ8VKZxz52VAxl1EBgKXXbTiU9Fc5fZeLn4=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I=
-google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA=
-google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
-google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
-google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
+gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0=
+gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY=
+google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
+google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0 h1:0UOBWO4dC+e51ui0NFKSPbkHHiQ4TmrEfEZMLDyRmY8=
+google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0/go.mod h1:8ytArBbtOy2xfht+y2fqKd5DRDJRUQhqbyEnQ4bDChs=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 h1:MAKi5q709QWfnkkpNQ0M12hYJ1+e8qYVDyowc4U1XZM=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
+google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
+google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
+google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
+google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
+google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
+google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@@ -224,21 +397,24 @@ gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSP
gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-k8s.io/api v0.34.0 h1:L+JtP2wDbEYPUeNGbeSa/5GwFtIA662EmT2YSLOkAVE=
-k8s.io/api v0.34.0/go.mod h1:YzgkIzOOlhl9uwWCZNqpw6RJy9L2FK4dlJeayUoydug=
-k8s.io/apiextensions-apiserver v0.34.0 h1:B3hiB32jV7BcyKcMU5fDaDxk882YrJ1KU+ZSkA9Qxoc=
-k8s.io/apiextensions-apiserver v0.34.0/go.mod h1:hLI4GxE1BDBy9adJKxUxCEHBGZtGfIg98Q+JmTD7+g0=
-k8s.io/apimachinery v0.34.0 h1:eR1WO5fo0HyoQZt1wdISpFDffnWOvFLOOeJ7MgIv4z0=
-k8s.io/apimachinery v0.34.0/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=
-k8s.io/apiserver v0.34.0 h1:Z51fw1iGMqN7uJ1kEaynf2Aec1Y774PqU+FVWCFV3Jg=
-k8s.io/apiserver v0.34.0/go.mod h1:52ti5YhxAvewmmpVRqlASvaqxt0gKJxvCeW7ZrwgazQ=
-k8s.io/client-go v0.34.0 h1:YoWv5r7bsBfb0Hs2jh8SOvFbKzzxyNo0nSb0zC19KZo=
-k8s.io/client-go v0.34.0/go.mod h1:ozgMnEKXkRjeMvBZdV1AijMHLTh3pbACPvK7zFR+QQY=
-k8s.io/component-base v0.34.0 h1:bS8Ua3zlJzapklsB1dZgjEJuJEeHjj8yTu1gxE2zQX8=
-k8s.io/component-base v0.34.0/go.mod h1:RSCqUdvIjjrEm81epPcjQ/DS+49fADvGSCkIP3IC6vg=
+gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
+gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
+k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM=
+k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk=
+k8s.io/apiextensions-apiserver v0.34.1 h1:NNPBva8FNAPt1iSVwIE0FsdrVriRXMsaWFMqJbII2CI=
+k8s.io/apiextensions-apiserver v0.34.1/go.mod h1:hP9Rld3zF5Ay2Of3BeEpLAToP+l4s5UlxiHfqRaRcMc=
+k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4=
+k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=
+k8s.io/apiserver v0.34.1 h1:U3JBGdgANK3dfFcyknWde1G6X1F4bg7PXuvlqt8lITA=
+k8s.io/apiserver v0.34.1/go.mod h1:eOOc9nrVqlBI1AFCvVzsob0OxtPZUCPiUJL45JOTBG0=
+k8s.io/client-go v0.34.1 h1:ZUPJKgXsnKwVwmKKdPfw4tB58+7/Ik3CrjOEhsiZ7mY=
+k8s.io/client-go v0.34.1/go.mod h1:kA8v0FP+tk6sZA0yKLRG67LWjqufAoSHA2xVGKw9Of8=
+k8s.io/component-base v0.34.1 h1:v7xFgG+ONhytZNFpIz5/kecwD+sUhVE6HU7qQUiRM4A=
+k8s.io/component-base v0.34.1/go.mod h1:mknCpLlTSKHzAQJJnnHVKqjxR7gBeHRv0rPXA7gdtQ0=
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA=
@@ -249,8 +425,12 @@ sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 h1:jpcvIRr3GLoUo
sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw=
sigs.k8s.io/controller-runtime v0.22.1 h1:Ah1T7I+0A7ize291nJZdS1CabF/lB4E++WizgV24Eqg=
sigs.k8s.io/controller-runtime v0.22.1/go.mod h1:FwiwRjkRPbiN+zp2QRp7wlTCzbUXxZ/D4OzuQUDwBHY=
+sigs.k8s.io/gateway-api v1.3.1-0.20250527223622-54df0a899c1c h1:GS4VnGRV90GEUjrgQ2GT5ii6yzWj3KtgUg+sVMdhs5c=
+sigs.k8s.io/gateway-api v1.3.1-0.20250527223622-54df0a899c1c/go.mod h1:d8NV8nJbaRbEKem+5IuxkL8gJGOZ+FJ+NvOIltV8gDk=
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE=
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
+sigs.k8s.io/multicluster-runtime v0.21.0-alpha.8 h1:Pq69tTKfN8ADw8m8A3wUtP8wJ9SPQbbOsgapm3BZEPw=
+sigs.k8s.io/multicluster-runtime v0.21.0-alpha.8/go.mod h1:CpBzLMLQKdm+UCchd2FiGPiDdCxM5dgCCPKuaQ6Fsv0=
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco=
diff --git a/hack/boilerplate.go.txt b/hack/boilerplate.go.txt
index 221dcbe..ea8ae64 100644
--- a/hack/boilerplate.go.txt
+++ b/hack/boilerplate.go.txt
@@ -1,15 +1 @@
-/*
-Copyright 2025.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
\ No newline at end of file
+// SPDX-License-Identifier: AGPL-3.0-only
diff --git a/internal/config/config.go b/internal/config/config.go
new file mode 100644
index 0000000..5de8746
--- /dev/null
+++ b/internal/config/config.go
@@ -0,0 +1,82 @@
+package config
+
+import (
+ multiclusterproviders "go.miloapis.com/milo/pkg/multicluster-runtime"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/client-go/rest"
+ "k8s.io/client-go/tools/clientcmd"
+
+ ctrl "sigs.k8s.io/controller-runtime"
+)
+
+// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
+// +k8s:defaulter-gen=true
+
+type DNSOperator struct {
+ metav1.TypeMeta `json:",inline"`
+
+ Discovery DiscoveryConfig `json:"discovery"`
+
+ DownstreamResourceManagement DownstreamResourceManagementConfig `json:"downstreamResourceManagement"`
+}
+
+// +k8s:deepcopy-gen=true
+
+type DiscoveryConfig struct {
+ // Mode is the mode that the operator should use to discover clusters.
+ //
+ // Defaults to "single"
+ Mode multiclusterproviders.Provider `json:"mode"`
+
+ // InternalServiceDiscovery will result in the operator to connect to internal
+ // service addresses for projects.
+ InternalServiceDiscovery bool `json:"internalServiceDiscovery"`
+
+ // DiscoveryKubeconfigPath is the path to the kubeconfig file to use for
+ // project discovery. When not provided, the operator will use the in-cluster
+ // config.
+ DiscoveryKubeconfigPath string `json:"discoveryKubeconfigPath"`
+
+ // ProjectKubeconfigPath is the path to the kubeconfig file to use as a
+ // template when connecting to project control planes. When not provided,
+ // the operator will use the in-cluster config.
+ ProjectKubeconfigPath string `json:"projectKubeconfigPath"`
+}
+
+func (c *DiscoveryConfig) DiscoveryRestConfig() (*rest.Config, error) {
+ if c.DiscoveryKubeconfigPath == "" {
+ return ctrl.GetConfig()
+ }
+
+ return clientcmd.BuildConfigFromFlags("", c.DiscoveryKubeconfigPath)
+}
+
+func (c *DiscoveryConfig) ProjectRestConfig() (*rest.Config, error) {
+ if c.ProjectKubeconfigPath == "" {
+ return ctrl.GetConfig()
+ }
+
+ return clientcmd.BuildConfigFromFlags("", c.ProjectKubeconfigPath)
+}
+
+// +k8s:deepcopy-gen=true
+
+type DownstreamResourceManagementConfig struct {
+ // DNSZoneAccountingNamespace is the namespace where the DNSZone accounting is performed.
+ //
+ // +default="datum-downstream-dnszone-accounting"
+ DNSZoneAccountingNamespace string `json:"dnsZoneAccountingNamespace"`
+
+ // KubeconfigPath is the path to the kubeconfig file to use when managing
+ // downstream resources. When not provided, the operator will use the
+ // in-cluster config.
+ KubeconfigPath string `json:"kubeconfigPath"`
+}
+
+func (c *DownstreamResourceManagementConfig) RestConfig() (*rest.Config, error) {
+ if c.KubeconfigPath == "" {
+ return ctrl.GetConfig()
+ }
+
+ return clientcmd.BuildConfigFromFlags("", c.KubeconfigPath)
+}
diff --git a/internal/config/groupversion_info.go b/internal/config/groupversion_info.go
new file mode 100644
index 0000000..a422b4e
--- /dev/null
+++ b/internal/config/groupversion_info.go
@@ -0,0 +1,17 @@
+package config
+
+import (
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ "sigs.k8s.io/controller-runtime/pkg/scheme"
+)
+
+var (
+ // GroupVersion is group version used to register these objects.
+ GroupVersion = schema.GroupVersion{Group: "dns.networking.miloapis.com", Version: "v1alpha1"}
+
+ // SchemeBuilder is used to add go types to the GroupVersionKind scheme.
+ SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion}
+
+ // AddToScheme adds the types in this group-version to the given scheme.
+ AddToScheme = SchemeBuilder.AddToScheme
+)
diff --git a/internal/config/zz_generated.deepcopy.go b/internal/config/zz_generated.deepcopy.go
new file mode 100644
index 0000000..b7526b9
--- /dev/null
+++ b/internal/config/zz_generated.deepcopy.go
@@ -0,0 +1,67 @@
+//go:build !ignore_autogenerated
+
+// SPDX-License-Identifier: AGPL-3.0-only
+
+// Code generated by controller-gen. DO NOT EDIT.
+
+package config
+
+import (
+ runtime "k8s.io/apimachinery/pkg/runtime"
+)
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *DNSOperator) DeepCopyInto(out *DNSOperator) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ out.Discovery = in.Discovery
+ out.DownstreamResourceManagement = in.DownstreamResourceManagement
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSOperator.
+func (in *DNSOperator) DeepCopy() *DNSOperator {
+ if in == nil {
+ return nil
+ }
+ out := new(DNSOperator)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *DNSOperator) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *DiscoveryConfig) DeepCopyInto(out *DiscoveryConfig) {
+ *out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DiscoveryConfig.
+func (in *DiscoveryConfig) DeepCopy() *DiscoveryConfig {
+ if in == nil {
+ return nil
+ }
+ out := new(DiscoveryConfig)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *DownstreamResourceManagementConfig) DeepCopyInto(out *DownstreamResourceManagementConfig) {
+ *out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DownstreamResourceManagementConfig.
+func (in *DownstreamResourceManagementConfig) DeepCopy() *DownstreamResourceManagementConfig {
+ if in == nil {
+ return nil
+ }
+ out := new(DownstreamResourceManagementConfig)
+ in.DeepCopyInto(out)
+ return out
+}
diff --git a/internal/config/zz_generated.defaults.go b/internal/config/zz_generated.defaults.go
new file mode 100644
index 0000000..fad9b60
--- /dev/null
+++ b/internal/config/zz_generated.defaults.go
@@ -0,0 +1,24 @@
+//go:build !ignore_autogenerated
+// +build !ignore_autogenerated
+
+// Code generated by defaulter-gen. DO NOT EDIT.
+
+package config
+
+import (
+ runtime "k8s.io/apimachinery/pkg/runtime"
+)
+
+// RegisterDefaults adds defaulters functions to the given scheme.
+// Public to allow building arbitrary schemes.
+// All generated defaulters are covering - they call all nested defaulters.
+func RegisterDefaults(scheme *runtime.Scheme) error {
+ scheme.AddTypeDefaultingFunc(&DNSOperator{}, func(obj interface{}) { SetObjectDefaults_DNSOperator(obj.(*DNSOperator)) })
+ return nil
+}
+
+func SetObjectDefaults_DNSOperator(in *DNSOperator) {
+ if in.DownstreamResourceManagement.DNSZoneAccountingNamespace == "" {
+ in.DownstreamResourceManagement.DNSZoneAccountingNamespace = "datum-downstream-dnszone-accounting"
+ }
+}
diff --git a/internal/controller/conditions.go b/internal/controller/conditions.go
new file mode 100644
index 0000000..e92df2a
--- /dev/null
+++ b/internal/controller/conditions.go
@@ -0,0 +1,13 @@
+package controller
+
+const (
+ CondAccepted = "Accepted"
+ CondProgrammed = "Programmed"
+ CondDiscovered = "Discovered"
+ ReasonAccepted = "Accepted"
+ ReasonPending = "Pending"
+ ReasonInvalidDNSRecordSet = "InvalidDNSRecordSet"
+ ReasonProgrammed = "Programmed"
+ ReasonDiscovered = "Discovered"
+ ReasonDNSZoneInUse = "DNSZoneInUse"
+)
diff --git a/internal/controller/const.go b/internal/controller/const.go
new file mode 100644
index 0000000..65108ba
--- /dev/null
+++ b/internal/controller/const.go
@@ -0,0 +1,5 @@
+package controller
+
+const (
+ ControllerNamePowerDNS = "powerdns"
+)
diff --git a/internal/controller/dnsrecordset_controller.go b/internal/controller/dnsrecordset_controller.go
deleted file mode 100644
index 32adc7e..0000000
--- a/internal/controller/dnsrecordset_controller.go
+++ /dev/null
@@ -1,62 +0,0 @@
-/*
-Copyright 2025.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-package controller
-
-import (
- "context"
-
- "k8s.io/apimachinery/pkg/runtime"
- ctrl "sigs.k8s.io/controller-runtime"
- "sigs.k8s.io/controller-runtime/pkg/client"
- logf "sigs.k8s.io/controller-runtime/pkg/log"
-)
-
-// DNSRecordSetReconciler reconciles a DNSRecordSet object
-type DNSRecordSetReconciler struct {
- client.Client
- Scheme *runtime.Scheme
-}
-
-// +kubebuilder:rbac:groups=dns.networking.miloapis.com,resources=dnsrecordsets,verbs=get;list;watch;create;update;patch;delete
-// +kubebuilder:rbac:groups=dns.networking.miloapis.com,resources=dnsrecordsets/status,verbs=get;update;patch
-// +kubebuilder:rbac:groups=dns.networking.miloapis.com,resources=dnsrecordsets/finalizers,verbs=update
-
-// Reconcile is part of the main kubernetes reconciliation loop which aims to
-// move the current state of the cluster closer to the desired state.
-// TODO(user): Modify the Reconcile function to compare the state specified by
-// the DNSRecordSet object against the actual cluster state, and then
-// perform operations to make the cluster state reflect the state specified by
-// the user.
-//
-// For more details, check Reconcile and its Result here:
-// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.22.1/pkg/reconcile
-func (r *DNSRecordSetReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
- _ = logf.FromContext(ctx)
-
- // TODO(user): your logic here
-
- return ctrl.Result{}, nil
-}
-
-// SetupWithManager sets up the controller with the Manager.
-func (r *DNSRecordSetReconciler) SetupWithManager(mgr ctrl.Manager) error {
- return ctrl.NewControllerManagedBy(mgr).
- // Uncomment the following line adding a pointer to an instance of the controlled resource as an argument
- // For().
- Named("dnsrecordset").
- Complete(r)
-}
diff --git a/internal/controller/dnsrecordset_controller_test.go b/internal/controller/dnsrecordset_controller_test.go
index 873a6a2..d5f5ee8 100644
--- a/internal/controller/dnsrecordset_controller_test.go
+++ b/internal/controller/dnsrecordset_controller_test.go
@@ -1,18 +1,4 @@
-/*
-Copyright 2025.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
+// SPDX-License-Identifier: AGPL-3.0-only
package controller
diff --git a/internal/controller/dnsrecordset_downstream_controller.go b/internal/controller/dnsrecordset_downstream_controller.go
new file mode 100644
index 0000000..82cc2fe
--- /dev/null
+++ b/internal/controller/dnsrecordset_downstream_controller.go
@@ -0,0 +1,299 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+
+package controller
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ apimeta "k8s.io/apimachinery/pkg/api/meta"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/client-go/util/workqueue"
+ ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/controller"
+ "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
+ "sigs.k8s.io/controller-runtime/pkg/handler"
+ logf "sigs.k8s.io/controller-runtime/pkg/log"
+ "sigs.k8s.io/controller-runtime/pkg/reconcile"
+
+ dnsv1alpha1 "go.miloapis.com/dns-operator/api/v1alpha1"
+ pdnsclient "go.miloapis.com/dns-operator/internal/pdns"
+)
+
+// DNSRecordSetReconciler reconciles a DNSRecordSet object
+type DNSRecordSetReconciler struct {
+ client.Client
+ Scheme *runtime.Scheme
+}
+
+// downstreamRSFinalizer is the finalizer for the DNSRecordSetDownstream controller
+const downstreamRSFinalizer = "dns.networking.miloapis.com/finalize-dnsrecordset-downstream"
+
+// +kubebuilder:rbac:groups=dns.networking.miloapis.com,resources=dnsrecordsets,verbs=get;list;watch;create;update;patch;delete
+// +kubebuilder:rbac:groups=dns.networking.miloapis.com,resources=dnsrecordsets/status,verbs=get;update;patch
+// +kubebuilder:rbac:groups=dns.networking.miloapis.com,resources=dnsrecordsets/finalizers,verbs=update
+// +kubebuilder:rbac:groups=dns.networking.miloapis.com,resources=dnszones,verbs=get;list;watch
+// +kubebuilder:rbac:groups=dns.networking.miloapis.com,resources=dnszoneclasses,verbs=get;list;watch
+
+// For more details, check Reconcile and its Result here:
+// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.22.1/pkg/reconcile
+func (r *DNSRecordSetReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
+ logger := logf.FromContext(ctx)
+ logger.Info("reconcile start")
+
+ var rs dnsv1alpha1.DNSRecordSet
+ if err := r.Get(ctx, req.NamespacedName, &rs); err != nil {
+ return ctrl.Result{}, client.IgnoreNotFound(err)
+ }
+
+ // --- Ensure finalizer on creation/update (non-deletion path) ---
+ if rs.DeletionTimestamp.IsZero() && !controllerutil.ContainsFinalizer(&rs, downstreamRSFinalizer) {
+ base := rs.DeepCopy()
+ controllerutil.AddFinalizer(&rs, downstreamRSFinalizer)
+ if err := r.Patch(ctx, &rs, client.MergeFrom(base)); err != nil {
+ logger.Error(err, "failed to add finalizer", "ns", rs.Namespace, "name", rs.Name)
+ return ctrl.Result{}, err
+ }
+ // After mutating the object, return so we reconcile again with updated state.
+ return ctrl.Result{}, nil
+ }
+
+ // --- Deletion path: MUST clean PDNS before removing finalizer ---
+ if !rs.DeletionTimestamp.IsZero() {
+ if controllerutil.ContainsFinalizer(&rs, downstreamRSFinalizer) {
+ // Fetch zone (if absent or empty => nothing to clean; treat as success)
+ var zone dnsv1alpha1.DNSZone
+ err := r.Get(ctx, client.ObjectKey{Namespace: req.Namespace, Name: rs.Spec.DNSZoneRef.Name}, &zone)
+ if err != nil {
+ // If the zone is already gone, treat as success: nothing to clean in PDNS
+ if client.IgnoreNotFound(err) == nil {
+ base := rs.DeepCopy()
+ controllerutil.RemoveFinalizer(&rs, downstreamRSFinalizer)
+ if err := r.Patch(ctx, &rs, client.MergeFrom(base)); err != nil {
+ logger.Error(err, "failed to remove finalizer after zone missing", "ns", rs.Namespace, "name", rs.Name)
+ return ctrl.Result{}, err
+ }
+ return ctrl.Result{}, nil
+ }
+ logger.Error(err, "failed to get zone", "ns", req.Namespace, "name", rs.Spec.DNSZoneRef.Name)
+ return ctrl.Result{}, err
+ }
+
+ // If the zone is being deleted, skip PDNS cleanup for this recordset
+ if !zone.DeletionTimestamp.IsZero() {
+ // remove our finalizer
+ base := rs.DeepCopy()
+
+ controllerutil.RemoveFinalizer(&rs, downstreamRSFinalizer)
+ if err := r.Patch(ctx, &rs, client.MergeFrom(base)); err != nil {
+ logger.Error(err, "failed to remove finalizer", "ns", rs.Namespace, "name", rs.Name)
+ return ctrl.Result{}, err
+ }
+
+ // Return early to avoid cleanup PDNS since those records will be deleted by the zone deletion
+ return ctrl.Result{}, nil
+ }
+
+ if err := r.cleanupPDNSForRecordSet(ctx, &rs, &zone); err != nil {
+ return ctrl.Result{}, fmt.Errorf("cleanup failed for zone %q: %w", zone.Spec.DomainName, err)
+ }
+
+ // PDNS cleanup succeeded (or was a no-op) -> remove finalizer (conflict-safe)
+ base := rs.DeepCopy()
+ controllerutil.RemoveFinalizer(&rs, downstreamRSFinalizer)
+ if err := r.Patch(ctx, &rs, client.MergeFrom(base)); err != nil {
+ return ctrl.Result{}, fmt.Errorf("failed to remove finalizer: %w", err)
+ }
+ }
+ return ctrl.Result{}, nil
+ }
+
+ // Fetch zone to locate class
+ var zone dnsv1alpha1.DNSZone
+ if err := r.Get(ctx, client.ObjectKey{Namespace: req.Namespace, Name: rs.Spec.DNSZoneRef.Name}, &zone); err != nil {
+ return ctrl.Result{}, client.IgnoreNotFound(err)
+ }
+
+ // Ensure the DNSZone is an owner of this DNSRecordSet so GC cascades on zone deletion.
+ if !metav1.IsControlledBy(&rs, &zone) {
+ logger.Info("rs is not controlled by zone; setting owner reference")
+ base := rs.DeepCopy()
+ if err := controllerutil.SetControllerReference(&zone, &rs, r.Scheme); err != nil {
+ logger.Error(err, "failed to set owner reference", "rs", rs.Name, "zone", zone.Name)
+ return ctrl.Result{}, err
+ }
+ if err := r.Patch(ctx, &rs, client.MergeFrom(base)); err != nil {
+ logger.Error(err, "failed to patch owner reference", "rs", rs.Name, "zone", zone.Name)
+ return ctrl.Result{}, err
+ }
+ return ctrl.Result{}, nil
+ }
+
+ // If the zone is deleting, do not attempt to program PDNS for this recordset
+ if !zone.DeletionTimestamp.IsZero() {
+ logger.Info("zone is deleting; skipping pdns program")
+ return ctrl.Result{}, nil
+ }
+ if zone.Spec.DNSZoneClassName == "" {
+ logger.Info("zone class not set; skipping pdns program")
+ return ctrl.Result{}, nil
+ }
+ var zc dnsv1alpha1.DNSZoneClass
+ if err := r.Get(ctx, client.ObjectKey{Name: zone.Spec.DNSZoneClassName}, &zc); err != nil {
+ logger.Info("zone class not found; skipping pdns program")
+ return ctrl.Result{}, client.IgnoreNotFound(err)
+ }
+ if zc.Spec.ControllerName != ControllerNamePowerDNS {
+ logger.Info("zone class controller not powerdns; skipping pdns program")
+ return ctrl.Result{}, nil
+ }
+
+ cli, err := pdnsclient.NewFromEnv()
+ if err != nil {
+ logger.Error(err, "pdns client init")
+ return ctrl.Result{}, fmt.Errorf("pdns client: %w", err)
+ }
+
+ logger.Info("pdns client initialized")
+
+ // Ensure the zone exists in PDNS before attempting to apply rrsets
+ if _, err := cli.GetZone(ctx, zone.Spec.DomainName); err != nil {
+ logger.Info("pdns zone not ready yet; requeueing", "zone", zone.Spec.DomainName, "err", err.Error())
+ // reflect not programmed (pending) while waiting on PDNS zone
+ base := rs.DeepCopy()
+ if apimeta.SetStatusCondition(&rs.Status.Conditions, metav1.Condition{
+ Type: CondProgrammed,
+ Status: metav1.ConditionFalse,
+ Reason: ReasonPending,
+ Message: fmt.Sprintf("PDNS zone %q not ready: %v", zone.Spec.DomainName, err),
+ ObservedGeneration: rs.Generation,
+ LastTransitionTime: metav1.NewTime(time.Now()),
+ }) {
+ if err := r.Status().Patch(ctx, &rs, client.MergeFrom(base)); err != nil {
+ return ctrl.Result{}, err
+ }
+ }
+ return ctrl.Result{}, nil
+ }
+
+ logger.Info("pdns zone ready")
+
+ if err := cli.ApplyRecordSetAuthoritative(ctx, zone.Spec.DomainName, rs); err != nil {
+ logger.Error(err, "apply pdns recordset")
+ base := rs.DeepCopy()
+ // surface PDNS error into status
+ if apimeta.SetStatusCondition(&rs.Status.Conditions, metav1.Condition{
+ Type: CondProgrammed,
+ Status: metav1.ConditionFalse,
+ Reason: ReasonInvalidDNSRecordSet,
+ Message: fmt.Sprintf("%v", err),
+ ObservedGeneration: rs.Generation,
+ LastTransitionTime: metav1.NewTime(time.Now()),
+ }) {
+ if err := r.Status().Patch(ctx, &rs, client.MergeFrom(base)); err != nil {
+ return ctrl.Result{}, err
+ }
+ }
+ return ctrl.Result{}, err
+ }
+
+ logger.Info("pdns apply succeeded")
+
+ // success: mark programmed true
+ base := rs.DeepCopy()
+ if apimeta.SetStatusCondition(&rs.Status.Conditions, metav1.Condition{
+ Type: CondProgrammed,
+ Status: metav1.ConditionTrue,
+ Reason: ReasonProgrammed,
+ Message: "PDNS apply succeeded",
+ ObservedGeneration: rs.Generation,
+ LastTransitionTime: metav1.NewTime(time.Now()),
+ }) {
+ if err := r.Status().Patch(ctx, &rs, client.MergeFrom(base)); err != nil {
+ return ctrl.Result{}, err
+ }
+ }
+
+ logger.Info("programmed condition set")
+
+ logger.Info("reconcile complete")
+
+ return ctrl.Result{}, nil
+}
+
+// SetupWithManager wires watches:
+// - Reconciles DNSRecordSet
+// - Requeues DNSRecordSets when their DNSZone (same ns, same spec.zoneName) changes
+// - Uses an exponential backoff rate limiter for gentle retries while waiting on zone readiness
+func (r *DNSRecordSetReconciler) SetupWithManager(mgr ctrl.Manager) error {
+ // index DNSRecordSet by spec.DNSZoneRef.Name for quick fan-out from a DNSZone event
+ if err := mgr.GetFieldIndexer().IndexField(context.Background(),
+ &dnsv1alpha1.DNSRecordSet{}, "spec.DNSZoneRef.Name",
+ func(obj client.Object) []string {
+ rs := obj.(*dnsv1alpha1.DNSRecordSet)
+ return []string{rs.Spec.DNSZoneRef.Name}
+ },
+ ); err != nil {
+ return err
+ }
+
+ rl := workqueue.NewTypedItemExponentialFailureRateLimiter[reconcile.Request](1*time.Second, 30*time.Second)
+
+ return ctrl.NewControllerManagedBy(mgr).
+ For(&dnsv1alpha1.DNSRecordSet{}).
+ // When a DNSZone in this namespace becomes ready, enqueue its recordsets
+ Watches(
+ &dnsv1alpha1.DNSZone{},
+ handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []ctrl.Request {
+ zone := obj.(*dnsv1alpha1.DNSZone)
+ var rrs dnsv1alpha1.DNSRecordSetList
+ _ = mgr.GetClient().List(ctx, &rrs,
+ client.InNamespace(zone.Namespace),
+ client.MatchingFields{"spec.DNSZoneRef.Name": zone.Name},
+ )
+ out := make([]ctrl.Request, 0, len(rrs.Items))
+ for i := range rrs.Items {
+ out = append(out, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(&rrs.Items[i])})
+ }
+ return out
+ }),
+ ).
+ WithOptions(controller.Options{
+ RateLimiter: rl,
+ }).
+ Named("dnsrecordset").
+ Complete(r)
+}
+
+// cleanupPDNSForRecordSet ensures the RRsets represented by rs are removed from PDNS.
+// Returns nil when cleanup is complete (or nothing to do), or error on failure.
+func (r *DNSRecordSetReconciler) cleanupPDNSForRecordSet(ctx context.Context, rs *dnsv1alpha1.DNSRecordSet, zone *dnsv1alpha1.DNSZone) error {
+ cli, err := pdnsclient.NewFromEnv()
+ if err != nil {
+ return fmt.Errorf("pdns client: %w", err)
+ }
+
+ // If PDNS zone doesn't exist, consider cleanup done.
+ if _, err := cli.GetZone(ctx, zone.Spec.DomainName); err != nil {
+ return nil
+ }
+
+ // Authoritatively apply an empty set for this recordset (delete semantics).
+ toDelete := rs.DeepCopy()
+ toDelete.Spec.Records = nil
+
+ // Bound the external call; but allow enough to finish deterministically.
+ pdnsCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
+ defer cancel()
+
+ if err := cli.ApplyRecordSetAuthoritative(pdnsCtx, zone.Spec.DomainName, *toDelete); err != nil {
+ // distinguish transient vs permanent if your client exposes that; otherwise retry
+ return fmt.Errorf("pdns apply delete: %w", err)
+ }
+
+ return nil
+}
diff --git a/internal/controller/dnsrecordset_replicator_controller.go b/internal/controller/dnsrecordset_replicator_controller.go
new file mode 100644
index 0000000..f538311
--- /dev/null
+++ b/internal/controller/dnsrecordset_replicator_controller.go
@@ -0,0 +1,333 @@
+package controller
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "k8s.io/apimachinery/pkg/api/equality"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ apimeta "k8s.io/apimachinery/pkg/api/meta"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/types"
+ ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/cluster"
+ "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
+ "sigs.k8s.io/controller-runtime/pkg/log"
+ mcbuilder "sigs.k8s.io/multicluster-runtime/pkg/builder"
+ mcmanager "sigs.k8s.io/multicluster-runtime/pkg/manager"
+ mcreconcile "sigs.k8s.io/multicluster-runtime/pkg/reconcile"
+ mcsource "sigs.k8s.io/multicluster-runtime/pkg/source"
+
+ dnsv1alpha1 "go.miloapis.com/dns-operator/api/v1alpha1"
+ downstreamclient "go.miloapis.com/dns-operator/internal/downstreamclient"
+)
+
+type DNSRecordSetReplicator struct {
+ mgr mcmanager.Manager
+ DownstreamClient client.Client
+}
+
+const rsFinalizer = "dns.networking.miloapis.com/finalize-dnsrecordset"
+
+// +kubebuilder:rbac:groups=dns.networking.miloapis.com,resources=dnsrecordsets,verbs=get;list;watch;update;patch;delete
+// +kubebuilder:rbac:groups=dns.networking.miloapis.com,resources=dnsrecordsets/status,verbs=get;update;patch
+// +kubebuilder:rbac:groups=dns.networking.miloapis.com,resources=dnsrecordsets/finalizers,verbs=update
+// +kubebuilder:rbac:groups=dns.networking.miloapis.com,resources=dnszones,verbs=get;list;watch
+// +kubebuilder:rbac:groups=core,resources=namespaces,verbs=get;list;watch
+
+func (r *DNSRecordSetReplicator) Reconcile(ctx context.Context, req mcreconcile.Request) (ctrl.Result, error) {
+ lg := log.FromContext(ctx).WithValues("cluster", req.ClusterName, "namespace", req.Namespace, "name", req.Name)
+ ctx = log.IntoContext(ctx, lg)
+ lg.Info("reconcile start")
+
+ upstreamCluster, err := r.mgr.GetCluster(ctx, req.ClusterName)
+ if err != nil {
+ return ctrl.Result{}, err
+ }
+
+ upstream, err := r.fetchUpstream(ctx, upstreamCluster, req.NamespacedName)
+ if err != nil {
+ return ctrl.Result{}, client.IgnoreNotFound(err)
+ }
+
+ strategy := downstreamclient.NewMappedNamespaceResourceStrategy(req.ClusterName, upstreamCluster.GetClient(), r.DownstreamClient)
+
+ // Ensure upstream finalizer (non-deletion path; replaces webhook defaulter)
+ if upstream.DeletionTimestamp.IsZero() && !controllerutil.ContainsFinalizer(&upstream, rsFinalizer) {
+ base := upstream.DeepCopy()
+ controllerutil.AddFinalizer(&upstream, rsFinalizer)
+ if err := upstreamCluster.GetClient().Patch(ctx, &upstream, client.MergeFrom(base)); err != nil {
+ log.FromContext(ctx).Error(err, "failed to add DNSRecordSet finalizer")
+ return ctrl.Result{}, err
+ }
+ lg.Info("added upstream finalizer", "finalizer", rsFinalizer)
+ return ctrl.Result{}, nil
+ }
+
+ // Deletion path: downstream-first via finalizer
+ if !upstream.DeletionTimestamp.IsZero() {
+ done, err := r.handleDeletion(ctx, upstreamCluster.GetClient(), strategy, &upstream)
+ if err != nil {
+ lg.Error(err, "deletion handling error; requeueing")
+ return ctrl.Result{}, err
+ }
+ if !done {
+ lg.Info("downstream DNSRecordSet still deleting; waiting for downstream update")
+ return ctrl.Result{}, nil
+ }
+ lg.Info("finalizer removed; allowing upstream DNSRecordSet to finalize")
+ // finalizer removed; allow upstream object to finalize
+ return ctrl.Result{}, nil
+ }
+
+ // Gate on referenced DNSZone early and update status when missing
+ var zoneMsg string
+ var zone dnsv1alpha1.DNSZone
+ if err := upstreamCluster.GetClient().Get(ctx, types.NamespacedName{Namespace: req.Namespace, Name: upstream.Spec.DNSZoneRef.Name}, &zone); err != nil {
+ if apierrors.IsNotFound(err) {
+ zoneMsg = fmt.Sprintf("DNSZone %q not found", upstream.Spec.DNSZoneRef.Name)
+ if apimeta.SetStatusCondition(&upstream.Status.Conditions, metav1.Condition{
+ Type: CondAccepted,
+ Status: metav1.ConditionFalse,
+ Reason: ReasonPending,
+ Message: zoneMsg,
+ ObservedGeneration: upstream.Generation,
+ LastTransitionTime: metav1.NewTime(time.Now()),
+ }) {
+ base := upstream.DeepCopy()
+ if err := upstreamCluster.GetClient().Status().Patch(ctx, &upstream, client.MergeFrom(base)); err != nil {
+ return ctrl.Result{}, err
+ }
+ }
+ lg.Info("referenced DNSZone not found; marked Accepted=False and exiting early", "dnsZone", upstream.Spec.DNSZoneRef.Name)
+ return ctrl.Result{}, nil
+ }
+ return ctrl.Result{}, err
+ } else {
+ zoneMsg = fmt.Sprintf("DNSZone %q exists", upstream.Spec.DNSZoneRef.Name)
+ }
+
+ // If the zone is being deleted, do not program downstream recordset
+ if !zone.DeletionTimestamp.IsZero() {
+ lg.Info("referenced DNSZone is deleting; skipping downstream programming", "dnsZone", zone.Name)
+ return ctrl.Result{}, nil
+ }
+
+ // Ensure OwnerReference to upstream DNSZone (same ns)
+ if !metav1.IsControlledBy(&upstream, &zone) {
+ base := upstream.DeepCopy()
+ if err := controllerutil.SetControllerReference(&zone, &upstream, upstreamCluster.GetScheme()); err != nil {
+ return ctrl.Result{}, err
+ }
+ if err := upstreamCluster.GetClient().Patch(ctx, &upstream, client.MergeFrom(base)); err != nil {
+ return ctrl.Result{}, err
+ }
+ // ensure we continue with the updated object
+ return ctrl.Result{}, nil
+ }
+ // Ensure the downstream recordset object mirrors the upstream spec
+ if _, err = r.ensureDownstreamRecordSet(ctx, strategy, &upstream); err != nil {
+ return ctrl.Result{}, err
+ }
+
+ // Mirror downstream Programmed condition into upstream, if present
+ var downstreamProg *metav1.Condition
+ if md, mdErr := strategy.ObjectMetaFromUpstreamObject(ctx, &upstream); mdErr == nil {
+ var shadow dnsv1alpha1.DNSRecordSet
+ if getErr := r.DownstreamClient.Get(ctx, types.NamespacedName{Namespace: md.Namespace, Name: md.Name}, &shadow); getErr == nil {
+ if c := apimeta.FindStatusCondition(shadow.Status.Conditions, CondProgrammed); c != nil {
+ downstreamProg = c.DeepCopy()
+ }
+ }
+ }
+
+ // Update upstream status: Accepted here; Programmed mirrored from downstream
+ if err := r.updateStatus(ctx, upstreamCluster.GetClient(), &upstream, true, zoneMsg, downstreamProg); err != nil {
+ if !apierrors.IsNotFound(err) { // tolerate races
+ return ctrl.Result{}, err
+ }
+ }
+
+ return ctrl.Result{}, nil
+}
+
+// ---- Helpers ---------------------------------------------------------------
+
+func (r *DNSRecordSetReplicator) fetchUpstream(ctx context.Context, cl cluster.Cluster, nn types.NamespacedName) (dnsv1alpha1.DNSRecordSet, error) {
+ var upstream dnsv1alpha1.DNSRecordSet
+ if err := cl.GetClient().Get(ctx, nn, &upstream); err != nil {
+ return dnsv1alpha1.DNSRecordSet{}, err
+ }
+ return upstream, nil
+}
+
+// handleDeletion deletes the downstream shadow object and removes the upstream finalizer
+// once the shadow is confirmed gone. It returns done=true only when the finalizer has been removed
+// (or when no finalizer is present). When the downstream still exists, it returns done=false, nil.
+func (r *DNSRecordSetReplicator) handleDeletion(
+ ctx context.Context,
+ upstreamClient client.Client,
+ strategy downstreamclient.ResourceStrategy,
+ upstream *dnsv1alpha1.DNSRecordSet,
+) (bool, error) {
+ // If no finalizer, nothing to enforce.
+ if !controllerutil.ContainsFinalizer(upstream, rsFinalizer) {
+ return true, nil
+ }
+
+ // Compute downstream name/namespace for this upstream object.
+ md, err := strategy.ObjectMetaFromUpstreamObject(ctx, upstream)
+ if err != nil {
+ return false, err
+ }
+
+ // Check if downstream shadow exists first.
+ var shadow dnsv1alpha1.DNSRecordSet
+ getErr := r.DownstreamClient.Get(ctx, types.NamespacedName{Namespace: md.Namespace, Name: md.Name}, &shadow)
+ if apierrors.IsNotFound(getErr) {
+ base := upstream.DeepCopy()
+ controllerutil.RemoveFinalizer(upstream, rsFinalizer)
+ if err := upstreamClient.Patch(ctx, upstream, client.MergeFrom(base)); err != nil {
+ return false, err
+ }
+ log.FromContext(ctx).Info("removed upstream finalizer", "finalizer", rsFinalizer)
+ return true, nil
+ }
+ if getErr != nil {
+ return false, getErr
+ }
+
+ // If the shadow is not already deleting, issue a delete now.
+ if shadow.DeletionTimestamp.IsZero() {
+ err = r.DownstreamClient.Delete(ctx, &shadow)
+ if err != nil && !apierrors.IsNotFound(err) {
+ return false, err
+ }
+ log.FromContext(ctx).Info("requested downstream delete for DNSRecordSet", "namespace", md.Namespace, "name", md.Name)
+ } else {
+ log.FromContext(ctx).Info("downstream DNSRecordSet already deleting; waiting", "namespace", md.Namespace, "name", md.Name)
+ }
+
+ // Still present—signal not done to trigger requeue by caller.
+ return false, nil
+}
+
+// ensureDownstreamRecordSet idempotently mirrors upstream.Spec into a downstream shadow object.
+func (r *DNSRecordSetReplicator) ensureDownstreamRecordSet(ctx context.Context, strategy downstreamclient.ResourceStrategy, upstream *dnsv1alpha1.DNSRecordSet) (controllerutil.OperationResult, error) {
+ md, err := strategy.ObjectMetaFromUpstreamObject(ctx, upstream)
+ if err != nil {
+ return controllerutil.OperationResultNone, err
+ }
+
+ shadow := dnsv1alpha1.DNSRecordSet{}
+ shadow.SetNamespace(md.Namespace)
+ shadow.SetName(md.Name)
+
+ res, cErr := controllerutil.CreateOrPatch(ctx, r.DownstreamClient, &shadow, func() error {
+ shadow.Labels = md.Labels
+ if !equality.Semantic.DeepEqual(shadow.Spec, upstream.Spec) {
+ shadow.Spec = upstream.Spec
+ }
+ return strategy.SetControllerReference(ctx, upstream, &shadow)
+ })
+ if cErr != nil {
+ return res, cErr
+ }
+ log.FromContext(ctx).Info("ensured downstream DNSRecordSet", "operation", res, "namespace", shadow.Namespace, "name", shadow.Name)
+ return res, nil
+}
+
+// updateStatus sets Accepted locally and mirrors the Programmed condition from downstream when provided.
+func (r *DNSRecordSetReplicator) updateStatus(ctx context.Context, c client.Client, upstream *dnsv1alpha1.DNSRecordSet, accepted bool, zoneMsg string, downstreamProg *metav1.Condition) error {
+ base := upstream.DeepCopy()
+ changed := false
+
+ // Accepted condition
+ if accepted {
+ if apimeta.SetStatusCondition(&upstream.Status.Conditions, metav1.Condition{
+ Type: CondAccepted,
+ Status: metav1.ConditionTrue,
+ Reason: ReasonAccepted,
+ Message: zoneMsg,
+ ObservedGeneration: upstream.Generation,
+ LastTransitionTime: metav1.NewTime(time.Now()),
+ }) {
+ changed = true
+ }
+ } else {
+ if apimeta.SetStatusCondition(&upstream.Status.Conditions, metav1.Condition{
+ Type: CondAccepted,
+ Status: metav1.ConditionFalse,
+ Reason: ReasonPending,
+ Message: zoneMsg,
+ ObservedGeneration: upstream.Generation,
+ LastTransitionTime: metav1.NewTime(time.Now()),
+ }) {
+ changed = true
+ }
+ }
+
+ // Programmed condition: mirror downstream when available; else mark pending
+ if downstreamProg != nil {
+ if apimeta.SetStatusCondition(&upstream.Status.Conditions, metav1.Condition{
+ Type: CondProgrammed,
+ Status: downstreamProg.Status,
+ Reason: downstreamProg.Reason,
+ Message: downstreamProg.Message,
+ ObservedGeneration: upstream.Generation,
+ LastTransitionTime: metav1.NewTime(time.Now()),
+ }) {
+ changed = true
+ }
+ } else {
+ if apimeta.SetStatusCondition(&upstream.Status.Conditions, metav1.Condition{
+ Type: CondProgrammed,
+ Status: metav1.ConditionFalse,
+ Reason: ReasonPending,
+ Message: "Awaiting downstream controller",
+ ObservedGeneration: upstream.Generation,
+ LastTransitionTime: metav1.NewTime(time.Now()),
+ }) {
+ changed = true
+ }
+
+ }
+
+ if !changed {
+ return nil
+ }
+ if err := c.Status().Patch(ctx, upstream, client.MergeFrom(base)); err != nil {
+ return err
+ }
+
+ log.FromContext(ctx).Info("upstream recordset status updated",
+ "accepted", apimeta.IsStatusConditionTrue(upstream.Status.Conditions, CondAccepted),
+ "programmed", apimeta.IsStatusConditionTrue(upstream.Status.Conditions, CondProgrammed),
+ )
+ return nil
+}
+
+// ---- Watches / mapping helpers -------------------------
+func (r *DNSRecordSetReplicator) SetupWithManager(mgr mcmanager.Manager, downstreamCl cluster.Cluster) error {
+ r.mgr = mgr
+
+ b := mcbuilder.ControllerManagedBy(mgr)
+
+ // Upstream watch (desired spec)
+ b = b.For(&dnsv1alpha1.DNSRecordSet{})
+
+ // Downstream watch (realized status → wake upstream owner)
+ src := mcsource.TypedKind(
+ &dnsv1alpha1.DNSRecordSet{},
+ downstreamclient.TypedEnqueueRequestForUpstreamOwner[*dnsv1alpha1.DNSRecordSet](&dnsv1alpha1.DNSRecordSet{}),
+ )
+ clusterSrc, err := src.ForCluster("", downstreamCl)
+ if err != nil {
+ return fmt.Errorf("failed to build downstream watch for %s: %w", dnsv1alpha1.GroupVersion.WithKind("DNSRecordSet").String(), err)
+ }
+ b = b.WatchesRawSource(clusterSrc)
+
+ return b.Named("dnsrecordset-replicator").Complete(r)
+}
diff --git a/internal/controller/dnszone_controller.go b/internal/controller/dnszone_controller.go
deleted file mode 100644
index 3954189..0000000
--- a/internal/controller/dnszone_controller.go
+++ /dev/null
@@ -1,62 +0,0 @@
-/*
-Copyright 2025.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-package controller
-
-import (
- "context"
-
- "k8s.io/apimachinery/pkg/runtime"
- ctrl "sigs.k8s.io/controller-runtime"
- "sigs.k8s.io/controller-runtime/pkg/client"
- logf "sigs.k8s.io/controller-runtime/pkg/log"
-)
-
-// DNSZoneReconciler reconciles a DNSZone object
-type DNSZoneReconciler struct {
- client.Client
- Scheme *runtime.Scheme
-}
-
-// +kubebuilder:rbac:groups=dns.networking.miloapis.com,resources=dnszones,verbs=get;list;watch;create;update;patch;delete
-// +kubebuilder:rbac:groups=dns.networking.miloapis.com,resources=dnszones/status,verbs=get;update;patch
-// +kubebuilder:rbac:groups=dns.networking.miloapis.com,resources=dnszones/finalizers,verbs=update
-
-// Reconcile is part of the main kubernetes reconciliation loop which aims to
-// move the current state of the cluster closer to the desired state.
-// TODO(user): Modify the Reconcile function to compare the state specified by
-// the DNSZone object against the actual cluster state, and then
-// perform operations to make the cluster state reflect the state specified by
-// the user.
-//
-// For more details, check Reconcile and its Result here:
-// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.22.1/pkg/reconcile
-func (r *DNSZoneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
- _ = logf.FromContext(ctx)
-
- // TODO(user): your logic here
-
- return ctrl.Result{}, nil
-}
-
-// SetupWithManager sets up the controller with the Manager.
-func (r *DNSZoneReconciler) SetupWithManager(mgr ctrl.Manager) error {
- return ctrl.NewControllerManagedBy(mgr).
- // Uncomment the following line adding a pointer to an instance of the controlled resource as an argument
- // For().
- Named("dnszone").
- Complete(r)
-}
diff --git a/internal/controller/dnszone_controller_test.go b/internal/controller/dnszone_controller_test.go
index df7d5f1..08ab208 100644
--- a/internal/controller/dnszone_controller_test.go
+++ b/internal/controller/dnszone_controller_test.go
@@ -1,18 +1,4 @@
-/*
-Copyright 2025.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
+// SPDX-License-Identifier: AGPL-3.0-only
package controller
diff --git a/internal/controller/dnszone_downstream_controller.go b/internal/controller/dnszone_downstream_controller.go
new file mode 100644
index 0000000..a1b678f
--- /dev/null
+++ b/internal/controller/dnszone_downstream_controller.go
@@ -0,0 +1,145 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+
+package controller
+
+import (
+ "context"
+ "fmt"
+
+ dnsv1alpha1 "go.miloapis.com/dns-operator/api/v1alpha1"
+ pdnsclient "go.miloapis.com/dns-operator/internal/pdns"
+ "k8s.io/apimachinery/pkg/runtime"
+ ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
+ logf "sigs.k8s.io/controller-runtime/pkg/log"
+)
+
+// DNSZoneReconciler reconciles a DNSZone object
+type DNSZoneReconciler struct {
+ client.Client
+ Scheme *runtime.Scheme
+}
+
+const downstreamZoneFinalizer = "dns.networking.miloapis.com/finalize-dnszone-downstream"
+
+// +kubebuilder:rbac:groups=dns.networking.miloapis.com,resources=dnszones,verbs=get;list;watch;create;update;patch;delete
+// +kubebuilder:rbac:groups=dns.networking.miloapis.com,resources=dnszones/status,verbs=get;update;patch
+// +kubebuilder:rbac:groups=dns.networking.miloapis.com,resources=dnszones/finalizers,verbs=update
+// +kubebuilder:rbac:groups=dns.networking.miloapis.com,resources=dnszoneclasses,verbs=get;list;watch
+
+// For more details, check Reconcile and its Result here:
+// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.22.1/pkg/reconcile
+func (r *DNSZoneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
+ logger := logf.FromContext(ctx)
+ logger.Info("dnszone reconcile start", "namespace", req.Namespace, "name", req.Name)
+
+ var zone dnsv1alpha1.DNSZone
+ if err := r.Get(ctx, req.NamespacedName, &zone); err != nil {
+ return ctrl.Result{}, client.IgnoreNotFound(err)
+ }
+
+ // --- Ensure finalizer (non-deletion path) ---
+ if zone.DeletionTimestamp.IsZero() {
+ if !controllerutil.ContainsFinalizer(&zone, downstreamZoneFinalizer) {
+
+ if controllerutil.ContainsFinalizer(&zone, downstreamZoneFinalizer) {
+ return ctrl.Result{}, nil
+ }
+ base := zone.DeepCopy()
+ controllerutil.AddFinalizer(&zone, downstreamZoneFinalizer)
+ if err := r.Patch(ctx, &zone, client.MergeFrom(base)); err != nil {
+ logger.Error(err, "failed to add zone finalizer")
+ return ctrl.Result{}, err
+ }
+ return ctrl.Result{}, nil
+ }
+ } else {
+ // --- Deletion path: remove from PDNS, then drop finalizer ---
+ if controllerutil.ContainsFinalizer(&zone, downstreamZoneFinalizer) {
+ // Only manage PDNS if this zone is handled by our controller
+ var zc dnsv1alpha1.DNSZoneClass
+ if zone.Spec.DNSZoneClassName != "" {
+ if err := r.Get(ctx, client.ObjectKey{Name: zone.Spec.DNSZoneClassName}, &zc); err != nil {
+ // TODO: should we delete if the class is not found?
+ return ctrl.Result{}, client.IgnoreNotFound(err)
+ }
+ }
+ if zc.Spec.ControllerName == ControllerNamePowerDNS {
+ cli, err := pdnsclient.NewFromEnv()
+ if err != nil {
+ logger.Error(err, "pdns client init")
+ return ctrl.Result{}, fmt.Errorf("pdns client: %w", err)
+ }
+ if err := cli.DeleteZone(ctx, zone.Spec.DomainName); err != nil {
+ logger.Error(err, "delete pdns zone failed; will retry", "zone", zone.Spec.DomainName)
+ return ctrl.Result{}, err
+ }
+ }
+
+ // remove finalizer
+ if !controllerutil.ContainsFinalizer(&zone, downstreamZoneFinalizer) {
+ return ctrl.Result{}, nil
+ }
+ base := zone.DeepCopy()
+ controllerutil.RemoveFinalizer(&zone, downstreamZoneFinalizer)
+ if err := r.Patch(ctx, &zone, client.MergeFrom(base)); err != nil {
+ logger.Error(err, "failed to remove zone finalizer")
+ return ctrl.Result{}, err
+ }
+ }
+ return ctrl.Result{}, nil
+ }
+
+ // If class is set and equals "powerdns", ensure in PDNS (status handled by replicator)
+ if zone.Spec.DNSZoneClassName != "" {
+ var zc dnsv1alpha1.DNSZoneClass
+ if err := r.Get(ctx, client.ObjectKey{Name: zone.Spec.DNSZoneClassName}, &zc); err != nil {
+ return ctrl.Result{}, client.IgnoreNotFound(err)
+ }
+ if zc.Spec.ControllerName == ControllerNamePowerDNS {
+ cli, err := pdnsclient.NewFromEnv()
+ if err != nil {
+ logger.Error(err, "pdns client init")
+ return ctrl.Result{}, fmt.Errorf("pdns client: %w", err)
+ }
+ // Ensure zone exists or create (no status updates here)
+ if _, err := cli.GetZone(ctx, zone.Spec.DomainName); err != nil {
+ // nameservers from class policy (Static)
+ var nss []string
+ if zc.Spec.NameServerPolicy != nil && zc.Spec.NameServerPolicy.Mode == dnsv1alpha1.NameServerPolicyModeStatic && zc.Spec.NameServerPolicy.Static != nil {
+ nss = append(nss, zc.Spec.NameServerPolicy.Static.Servers...)
+ }
+ if err := cli.CreateZone(ctx, zone.Spec.DomainName, nss); err != nil {
+ logger.Error(err, "create pdns zone")
+ return ctrl.Result{}, err
+ }
+ }
+
+ // At this point the zone exists in PDNS; set downstream status nameservers from class if not already set
+ var desiredNS []string
+ if zc.Spec.NameServerPolicy != nil && zc.Spec.NameServerPolicy.Mode == dnsv1alpha1.NameServerPolicyModeStatic && zc.Spec.NameServerPolicy.Static != nil {
+ desiredNS = append(desiredNS, zc.Spec.NameServerPolicy.Static.Servers...)
+ }
+ // Do not override once nameservers have been set downstream
+ if len(zone.Status.Nameservers) == 0 && len(desiredNS) > 0 {
+ base := zone.DeepCopy()
+ zone.Status.Nameservers = desiredNS
+ if err := r.Status().Patch(ctx, &zone, client.MergeFrom(base)); err != nil {
+ logger.Error(err, "failed to update downstream nameservers status; will retry")
+ return ctrl.Result{}, err
+ }
+ }
+ }
+ }
+
+ return ctrl.Result{}, nil
+}
+
+// SetupWithManager sets up the controller with the Manager.
+func (r *DNSZoneReconciler) SetupWithManager(mgr ctrl.Manager) error {
+ return ctrl.NewControllerManagedBy(mgr).
+ For(&dnsv1alpha1.DNSZone{}).
+ Named("dnszone").
+ Complete(r)
+}
diff --git a/internal/controller/dnszone_replicator_controller.go b/internal/controller/dnszone_replicator_controller.go
new file mode 100644
index 0000000..049a12f
--- /dev/null
+++ b/internal/controller/dnszone_replicator_controller.go
@@ -0,0 +1,744 @@
+package controller
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ corev1 "k8s.io/api/core/v1"
+ "k8s.io/apimachinery/pkg/api/equality"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ apimeta "k8s.io/apimachinery/pkg/api/meta"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/fields"
+ "k8s.io/apimachinery/pkg/types"
+ ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/cluster"
+ "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
+ "sigs.k8s.io/controller-runtime/pkg/handler"
+ "sigs.k8s.io/controller-runtime/pkg/log"
+ crreconcile "sigs.k8s.io/controller-runtime/pkg/reconcile"
+ mcbuilder "sigs.k8s.io/multicluster-runtime/pkg/builder"
+ mcmanager "sigs.k8s.io/multicluster-runtime/pkg/manager"
+ mcreconcile "sigs.k8s.io/multicluster-runtime/pkg/reconcile"
+ mcsource "sigs.k8s.io/multicluster-runtime/pkg/source"
+
+ networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha"
+ dnsv1alpha1 "go.miloapis.com/dns-operator/api/v1alpha1"
+ downstreamclient "go.miloapis.com/dns-operator/internal/downstreamclient"
+)
+
+type DNSZoneReplicator struct {
+ mgr mcmanager.Manager
+ DownstreamClient client.Client
+ // AccountingNamespace is the downstream namespace used for DNSZone ownership accounting.
+ AccountingNamespace string
+}
+
+const dnsZoneFinalizer = "dns.networking.miloapis.com/finalize-dnszone"
+
+// +kubebuilder:rbac:groups=dns.networking.miloapis.com,resources=dnszones,verbs=get;list;watch;create;update;patch;delete
+// +kubebuilder:rbac:groups=dns.networking.miloapis.com,resources=dnszones/status,verbs=get;update;patch
+// +kubebuilder:rbac:groups=dns.networking.miloapis.com,resources=dnszones/finalizers,verbs=update;patch;delete
+// +kubebuilder:rbac:groups=dns.networking.miloapis.com,resources=dnsrecordsets,verbs=get;list;watch;create;update;patch;delete
+// +kubebuilder:rbac:groups=dns.networking.miloapis.com,resources=dnsrecordsets/status,verbs=get;update;patch
+// +kubebuilder:rbac:groups=networking.datumapis.com,resources=domains,verbs=get;list;watch;create;update;patch;delete
+// +kubebuilder:rbac:groups=networking.datumapis.com,resources=domains/status,verbs=get;list;watch
+// +kubebuilder:rbac:groups=core,resources=namespaces,verbs=get;list;watch;create;update;patch
+// +kubebuilder:rbac:groups=core,resources=configmaps,verbs=get;list;watch;create;update;patch;delete
+
+func (r *DNSZoneReplicator) Reconcile(ctx context.Context, req mcreconcile.Request) (ctrl.Result, error) {
+ lg := log.FromContext(ctx).WithValues("cluster", req.ClusterName, "namespace", req.Namespace, "name", req.Name)
+ ctx = log.IntoContext(ctx, lg)
+ lg.Info("reconcile start")
+
+ upstreamCl, err := r.mgr.GetCluster(ctx, req.ClusterName)
+ if err != nil {
+ return ctrl.Result{}, err
+ }
+
+ // 1) Fetch upstream
+ upstream, err := r.fetchUpstream(ctx, upstreamCl, req.NamespacedName)
+ if err != nil {
+ return ctrl.Result{}, client.IgnoreNotFound(err)
+ }
+
+ // --- Ensure finalizer on creation/update (non-deletion path) ---
+ if upstream.DeletionTimestamp.IsZero() {
+ if !controllerutil.ContainsFinalizer(&upstream, dnsZoneFinalizer) {
+ base := upstream.DeepCopy()
+ upstream.Finalizers = append(upstream.Finalizers, dnsZoneFinalizer)
+ if err := upstreamCl.GetClient().Patch(ctx, &upstream, client.MergeFrom(base)); err != nil {
+ lg.Error(err, "failed to add upstream finalizer")
+ return ctrl.Result{}, err
+ }
+ lg.Info("added upstream finalizer", "finalizer", dnsZoneFinalizer)
+ // Re-run with updated object
+ return ctrl.Result{}, nil
+ }
+ } else {
+ // Deletion guard: ensure downstream is gone before removing finalizer
+ if controllerutil.ContainsFinalizer(&upstream, dnsZoneFinalizer) {
+ strategy := downstreamclient.NewMappedNamespaceResourceStrategy(req.ClusterName, upstreamCl.GetClient(), r.DownstreamClient)
+
+ // Request deletion of downstream anchor/shadow
+ if err := r.handleDeletion(ctx, strategy, &upstream); err != nil && !apierrors.IsNotFound(err) {
+ lg.Error(err, "downstream delete failed; will retry")
+ return ctrl.Result{}, err
+ }
+ lg.Info("requested downstream delete for DNSZone")
+
+ // Verify downstream object is actually gone before dropping finalizer
+ md, mdErr := strategy.ObjectMetaFromUpstreamObject(ctx, &upstream)
+ if mdErr != nil {
+ lg.Error(mdErr, "failed to compute downstream metadata; will retry")
+ return ctrl.Result{}, err
+ }
+ var shadow dnsv1alpha1.DNSZone
+ shadow.SetNamespace(md.Namespace)
+ shadow.SetName(md.Name)
+ getErr := r.DownstreamClient.Get(ctx, client.ObjectKey{Namespace: md.Namespace, Name: md.Name}, &shadow)
+ if getErr == nil {
+ lg.Info("downstream DNSZone still exists; waiting", "namespace", md.Namespace, "name", md.Name)
+ return ctrl.Result{}, nil
+ }
+ if !apierrors.IsNotFound(getErr) {
+ // Transient error; retry
+ lg.Error(getErr, "failed to check downstream deletion; will retry")
+ return ctrl.Result{}, err
+ }
+
+ // Cleanup zone accounting configmap once downstream is confirmed gone
+ lg.Info("downstream DNSZone confirmed deleted", "namespace", md.Namespace, "name", md.Name)
+ owner := fmt.Sprintf("%s/%s/%s", req.ClusterName, upstream.Namespace, upstream.Name)
+ if cerr := r.cleanupZoneAccounting(ctx, &upstream, owner); cerr != nil && !apierrors.IsNotFound(cerr) {
+ lg.Error(cerr, "failed to cleanup accounting configmap; will retry")
+ return ctrl.Result{}, err
+ }
+ lg.Info("cleaned up accounting configmap for DNSZone", "domain", upstream.Spec.DomainName)
+
+ // Downstream is confirmed gone -> remove upstream finalizer (conflict-safe)
+ base := upstream.DeepCopy()
+ controllerutil.RemoveFinalizer(&upstream, dnsZoneFinalizer)
+ if err := upstreamCl.GetClient().Patch(ctx, &upstream, client.MergeFrom(base)); err != nil {
+ lg.Error(err, "failed to remove upstream finalizer")
+ return ctrl.Result{}, err
+ }
+ lg.Info("removed upstream finalizer", "finalizer", dnsZoneFinalizer)
+ }
+ return ctrl.Result{}, nil
+ }
+
+ // If DNSZoneClassName is not set, set Accepted=False with reason
+ if upstream.Spec.DNSZoneClassName == "" {
+ base := upstream.DeepCopy() // take snapshot BEFORE mutating status
+ if apimeta.SetStatusCondition(&upstream.Status.Conditions, metav1.Condition{
+ Type: CondAccepted,
+ Status: metav1.ConditionFalse,
+ Reason: ReasonPending,
+ Message: "DNSZoneClassName not set",
+ ObservedGeneration: upstream.Generation,
+ LastTransitionTime: metav1.NewTime(time.Now()),
+ }) {
+ if err := upstreamCl.GetClient().Status().Patch(ctx, &upstream, client.MergeFrom(base)); err != nil {
+ return ctrl.Result{}, err
+ }
+ }
+ lg.Info("DNSZoneClassName not set; marked Accepted=False and exiting early")
+ return ctrl.Result{}, nil
+ }
+
+ strategy := downstreamclient.NewMappedNamespaceResourceStrategy(req.ClusterName, upstreamCl.GetClient(), r.DownstreamClient)
+ // 2) Resolve DNSZoneClass early; if specified but not found, set Accepted=False with message and stop
+ var zoneClass dnsv1alpha1.DNSZoneClass
+ if err := upstreamCl.GetClient().Get(ctx, client.ObjectKey{Name: upstream.Spec.DNSZoneClassName}, &zoneClass); err != nil {
+ if apierrors.IsNotFound(err) {
+ base := upstream.DeepCopy()
+ if apimeta.SetStatusCondition(&upstream.Status.Conditions, metav1.Condition{
+ Type: CondAccepted,
+ Status: metav1.ConditionFalse,
+ Reason: ReasonPending,
+ Message: fmt.Sprintf("DNSZoneClass %q not found", upstream.Spec.DNSZoneClassName),
+ ObservedGeneration: upstream.Generation,
+ LastTransitionTime: metav1.NewTime(time.Now()),
+ }) {
+ if err := upstreamCl.GetClient().Status().Patch(ctx, &upstream, client.MergeFrom(base)); err != nil {
+ return ctrl.Result{}, err
+ }
+ }
+ lg.Info("DNSZoneClass not found; marked Accepted=False and exiting early", "class", upstream.Spec.DNSZoneClassName)
+ return ctrl.Result{}, nil
+ }
+ return ctrl.Result{}, err
+ }
+
+ // 3) Ensure downstream shadow (only after class presence check)
+ owner := fmt.Sprintf("%s/%s/%s", req.ClusterName, upstream.Namespace, upstream.Name)
+ if owned, aerr := r.ensureZoneAccounting(ctx, &upstream, owner); aerr != nil {
+ return ctrl.Result{}, aerr
+ } else if !owned {
+ base := upstream.DeepCopy()
+ changed := false
+ if apimeta.SetStatusCondition(&upstream.Status.Conditions, metav1.Condition{
+ Type: CondAccepted,
+ Status: metav1.ConditionFalse,
+ Reason: ReasonDNSZoneInUse,
+ Message: "DNSZone claimed by another resource",
+ ObservedGeneration: upstream.Generation,
+ LastTransitionTime: metav1.NewTime(time.Now()),
+ }) {
+ changed = true
+ }
+ if apimeta.SetStatusCondition(&upstream.Status.Conditions, metav1.Condition{
+ Type: CondProgrammed,
+ Status: metav1.ConditionFalse,
+ Reason: ReasonDNSZoneInUse,
+ Message: "DNSZone claimed by another resource",
+ ObservedGeneration: upstream.Generation,
+ LastTransitionTime: metav1.NewTime(time.Now()),
+ }) {
+ changed = true
+ }
+ if changed {
+ if perr := upstreamCl.GetClient().Status().Patch(ctx, &upstream, client.MergeFrom(base)); perr != nil {
+ return ctrl.Result{}, perr
+ }
+ }
+ return ctrl.Result{}, nil
+ }
+ if _, err = r.ensureDownstreamZone(ctx, strategy, &upstream); err != nil {
+ return ctrl.Result{}, err
+ }
+
+ // Ensure a matching Domain exists in the upstream cluster for this zone's domain name.
+ if err := r.ensureDomain(ctx, upstreamCl.GetClient(), &upstream); err != nil {
+ return ctrl.Result{}, err
+ }
+
+ // 4) First, refresh upstream status from downstream (populate nameservers)
+ if err := r.updateStatus(ctx, upstreamCl.GetClient(), strategy, &upstream); err != nil {
+ if !apierrors.IsNotFound(err) { // tolerate races
+ return ctrl.Result{}, err
+ }
+ }
+
+ // If nameservers are not yet present, rely on downstream watch to trigger requeue
+ if len(upstream.Status.Nameservers) == 0 {
+ lg.Info("nameservers not yet available; waiting for downstream update")
+ return ctrl.Result{}, nil
+ }
+
+ // 5) Ensure default records exist (nameservers known by guard above)
+ if err := r.ensureSOARecordSet(ctx, upstreamCl.GetClient(), &upstream); err != nil {
+ return ctrl.Result{}, err
+ }
+ if err := r.ensureNSRecordSet(ctx, upstreamCl.GetClient(), &upstream); err != nil {
+ return ctrl.Result{}, err
+ }
+ // Recompute status to set Programmed based on record presence
+ if err := r.updateStatus(ctx, upstreamCl.GetClient(), strategy, &upstream); err != nil {
+ if !apierrors.IsNotFound(err) {
+ return ctrl.Result{}, err
+ }
+ }
+
+ return ctrl.Result{}, nil
+}
+
+// ---- Helpers ---------------------------------------------------------------
+
+func (r *DNSZoneReplicator) fetchUpstream(ctx context.Context, cl cluster.Cluster, nn types.NamespacedName) (dnsv1alpha1.DNSZone, error) {
+ var upstream dnsv1alpha1.DNSZone
+ if err := cl.GetClient().Get(ctx, nn, &upstream); err != nil {
+ return dnsv1alpha1.DNSZone{}, err
+ }
+ return upstream, nil
+}
+
+func (r *DNSZoneReplicator) handleDeletion(ctx context.Context, strategy downstreamclient.ResourceStrategy, upstream *dnsv1alpha1.DNSZone) error {
+ return strategy.DeleteAnchorForObject(ctx, upstream)
+}
+
+// ensureDownstreamZone mirrors upstream.Spec into a downstream shadow object idempotently.
+func (r *DNSZoneReplicator) ensureDownstreamZone(ctx context.Context, strategy downstreamclient.ResourceStrategy, upstream *dnsv1alpha1.DNSZone) (controllerutil.OperationResult, error) {
+ md, err := strategy.ObjectMetaFromUpstreamObject(ctx, upstream)
+ if err != nil {
+ return controllerutil.OperationResultNone, err
+ }
+
+ shadow := dnsv1alpha1.DNSZone{}
+ shadow.SetNamespace(md.Namespace)
+ shadow.SetName(md.Name)
+
+ res, cErr := controllerutil.CreateOrPatch(ctx, strategy.GetClient(), &shadow, func() error {
+ shadow.Labels = md.Labels
+ if !equality.Semantic.DeepEqual(shadow.Spec, upstream.Spec) {
+ shadow.Spec = upstream.Spec
+ }
+ return strategy.SetControllerReference(ctx, upstream, &shadow)
+ })
+ if cErr != nil {
+ return res, cErr
+ }
+ log.FromContext(ctx).Info("ensured downstream DNSZone", "operation", res, "namespace", shadow.Namespace, "name", shadow.Name)
+ return res, nil
+}
+
+// ensureZoneAccounting ensures a ConfigMap exists in the accounting namespace keyed by domainName,
+// and that its Data["owner"] matches the provided owner value. It creates the namespace/configmap if needed.
+// Returns owned=true when this zone owns the ConfigMap; owned=false if another owner holds it.
+func (r *DNSZoneReplicator) ensureZoneAccounting(ctx context.Context, upstream *dnsv1alpha1.DNSZone, owner string) (bool, error) {
+ ns := r.AccountingNamespace
+ if ns == "" {
+ // This should never happen, but if it does, we should log an error and return an error.
+ log.FromContext(ctx).Error(fmt.Errorf("accounting namespace is not set"), "ensureZoneAccounting")
+ return false, fmt.Errorf("accounting namespace is not set")
+ }
+ // Ensure namespace exists
+ var namespace corev1.Namespace
+ if err := r.DownstreamClient.Get(ctx, client.ObjectKey{Name: ns}, &namespace); err != nil {
+ if apierrors.IsNotFound(err) {
+ namespace = corev1.Namespace{}
+ namespace.Name = ns
+ if cerr := r.DownstreamClient.Create(ctx, &namespace); cerr != nil && !apierrors.IsAlreadyExists(cerr) {
+ return false, cerr
+ }
+ log.FromContext(ctx).Info("created accounting namespace (downstream)", "namespace", ns)
+ } else {
+ return false, err
+ }
+ }
+
+ var cm corev1.ConfigMap
+ if err := r.DownstreamClient.Get(ctx, client.ObjectKey{Namespace: ns, Name: upstream.Spec.DomainName}, &cm); err != nil {
+ if !apierrors.IsNotFound(err) {
+ return false, err
+ }
+ // Create new ownership CM
+ newCM := corev1.ConfigMap{}
+ newCM.Namespace = ns
+ newCM.Name = upstream.Spec.DomainName
+ newCM.Data = map[string]string{
+ "owner": owner,
+ }
+ if cerr := r.DownstreamClient.Create(ctx, &newCM); cerr != nil {
+ // A race can occur; if created by another, treat as not owned and let next reconcile decide
+ if apierrors.IsAlreadyExists(cerr) {
+ // Re-fetch and compare
+ if gerr := r.DownstreamClient.Get(ctx, client.ObjectKey{Namespace: ns, Name: upstream.Spec.DomainName}, &cm); gerr == nil {
+ owned := cm.Data["owner"] == owner
+ log.FromContext(ctx).Info("zone accounting exists after race", "namespace", ns, "configmap", upstream.Spec.DomainName, "owned", owned, "owner", cm.Data["owner"])
+ return owned, nil
+ }
+ }
+ return false, cerr
+ }
+ log.FromContext(ctx).Info("created zone accounting configmap (downstream)", "namespace", ns, "configmap", newCM.Name, "owner", owner)
+ return true, nil
+ }
+
+ // Exists: check ownership
+ owned := cm.Data["owner"] == owner
+ log.FromContext(ctx).Info("zone accounting found (downstream)", "namespace", ns, "configmap", cm.Name, "owned", owned, "owner", cm.Data["owner"])
+ return owned, nil
+}
+
+// cleanupZoneAccounting deletes the ownership ConfigMap for a zone if it is owned by the provided owner.
+func (r *DNSZoneReplicator) cleanupZoneAccounting(ctx context.Context, upstream *dnsv1alpha1.DNSZone, owner string) error {
+ ns := r.AccountingNamespace
+ if ns == "" {
+ // This should never happen, but if it does, we should log an error and return an error.
+ log.FromContext(ctx).Error(fmt.Errorf("accounting namespace is not set"), "cleanupZoneAccounting")
+ return fmt.Errorf("accounting namespace is not set")
+ }
+ var cm corev1.ConfigMap
+ if err := r.DownstreamClient.Get(ctx, client.ObjectKey{Namespace: ns, Name: upstream.Spec.DomainName}, &cm); err != nil {
+ if apierrors.IsNotFound(err) {
+ return nil
+ }
+ return err
+ }
+ if cm.Data["owner"] != owner {
+ // Do not delete if not owned by us
+ log.FromContext(ctx).Info("skipping accounting configmap delete; not owner", "namespace", ns, "configmap", cm.Name, "owner", cm.Data["owner"], "expectedOwner", owner)
+ return nil
+ }
+ if err := r.DownstreamClient.Delete(ctx, &cm); err != nil {
+ if apierrors.IsNotFound(err) {
+ return nil
+ }
+ return err
+ }
+ log.FromContext(ctx).Info("deleted accounting configmap (downstream)", "namespace", ns, "configmap", cm.Name)
+ return nil
+}
+
+// ensureSOARecordSet guarantees there is a managed SOA DNSRecordSet for PDNS-backed zones.
+// It creates or patches a DNSRecordSet named "soa" in the same namespace that targets the zone root ("@")
+// and uses the typed SOA fields with defaults derived from the zone name.
+func (r *DNSZoneReplicator) ensureSOARecordSet(ctx context.Context, c client.Client, upstream *dnsv1alpha1.DNSZone) error {
+ // Build desired SOA DNSRecordSet using nameservers from upstream status
+ if len(upstream.Status.Nameservers) == 0 {
+ return nil
+ }
+ mname := upstream.Status.Nameservers[0]
+ if mname != "" && mname[len(mname)-1] != '.' {
+ mname += "."
+ }
+ rname := "hostmaster." + upstream.Spec.DomainName + "."
+ rsName := "soa"
+
+ // if an SOA recordset for this zone already exists, do nothing
+ var existingList dnsv1alpha1.DNSRecordSetList
+ if err := c.List(
+ ctx,
+ &existingList,
+ client.InNamespace(upstream.Namespace),
+ client.MatchingFieldsSelector{Selector: fields.AndSelectors(
+ fields.OneTermEqualSelector("spec.dnsZoneRef.name", upstream.Name),
+ fields.OneTermEqualSelector("spec.recordType", string(dnsv1alpha1.RRTypeSOA)),
+ )},
+ ); err != nil {
+ return err
+ }
+ if len(existingList.Items) > 0 {
+ log.FromContext(ctx).Info("SOA DNSRecordSet already present; skipping create", "namespace", upstream.Namespace, "dnsZone", upstream.Name)
+ return nil
+ }
+
+ newObj := dnsv1alpha1.DNSRecordSet{}
+ newObj.SetNamespace(upstream.Namespace)
+ newObj.SetName(fmt.Sprintf("%s-%s", upstream.Name, rsName))
+ newObj.Spec = dnsv1alpha1.DNSRecordSetSpec{
+ DNSZoneRef: corev1.LocalObjectReference{Name: upstream.Name},
+ RecordType: dnsv1alpha1.RRTypeSOA,
+ Records: []dnsv1alpha1.RecordEntry{{
+ Name: "@",
+ SOA: &dnsv1alpha1.SOARecordSpec{
+ MName: mname,
+ RName: rname,
+ Refresh: 10800,
+ Retry: 3600,
+ Expire: 604800,
+ TTL: 3600,
+ },
+ }},
+ }
+ log.FromContext(ctx).Info("creating default SOA DNSRecordSet (upstream)", "namespace", newObj.Namespace, "dnsZone", upstream.Name)
+ return c.Create(ctx, &newObj)
+}
+
+// ensureNSRecordSet ensures a root NS recordset reflecting nameservers from the upstream status.
+func (r *DNSZoneReplicator) ensureNSRecordSet(ctx context.Context, c client.Client, upstream *dnsv1alpha1.DNSZone) error {
+ if len(upstream.Status.Nameservers) == 0 {
+ return nil
+ }
+
+ // Build desired NS DNSRecordSet at root from upstream status nameservers
+ rsName := "ns"
+ // if an NS recordset for this zone already exists, do nothing
+ var existingList dnsv1alpha1.DNSRecordSetList
+ if err := c.List(
+ ctx,
+ &existingList,
+ client.InNamespace(upstream.Namespace),
+ client.MatchingFieldsSelector{Selector: fields.AndSelectors(
+ fields.OneTermEqualSelector("spec.dnsZoneRef.name", upstream.Name),
+ fields.OneTermEqualSelector("spec.recordType", string(dnsv1alpha1.RRTypeNS)),
+ )},
+ ); err != nil {
+ return err
+ }
+ if len(existingList.Items) > 0 {
+ log.FromContext(ctx).Info("NS DNSRecordSet already present; skipping create", "namespace", upstream.Namespace, "dnsZone", upstream.Name)
+ return nil
+ }
+
+ records := make([]dnsv1alpha1.RecordEntry, 0, len(upstream.Status.Nameservers))
+ for _, value := range upstream.Status.Nameservers {
+ records = append(records, dnsv1alpha1.RecordEntry{
+ Name: "@",
+ NS: &dnsv1alpha1.NSRecordSpec{
+ Content: value,
+ },
+ })
+ }
+ newObj := dnsv1alpha1.DNSRecordSet{}
+ newObj.SetNamespace(upstream.Namespace)
+ newObj.SetName(fmt.Sprintf("%s-%s", upstream.Name, rsName))
+ newObj.Spec = dnsv1alpha1.DNSRecordSetSpec{
+ DNSZoneRef: corev1.LocalObjectReference{Name: upstream.Name},
+ RecordType: dnsv1alpha1.RRTypeNS,
+ Records: records,
+ }
+ log.FromContext(ctx).Info("creating default NS DNSRecordSet (upstream)", "namespace", newObj.Namespace, "dnsZone", upstream.Name)
+ return c.Create(ctx, &newObj)
+}
+
+// updateStatus owns the upstream status synthesis: Accepted/Programmed, Nameservers.
+func (r *DNSZoneReplicator) updateStatus(ctx context.Context, c client.Client, strategy downstreamclient.ResourceStrategy, upstream *dnsv1alpha1.DNSZone) error {
+ base := upstream.DeepCopy()
+ changed := false
+
+ // Nameservers: mirror from downstream DNSZone status
+ if strategy != nil {
+ if md, err := strategy.ObjectMetaFromUpstreamObject(ctx, upstream); err == nil {
+ var shadow dnsv1alpha1.DNSZone
+ shadow.SetNamespace(md.Namespace)
+ shadow.SetName(md.Name)
+ if err := r.DownstreamClient.Get(ctx, client.ObjectKey{Namespace: md.Namespace, Name: md.Name}, &shadow); err == nil {
+ if !equality.Semantic.DeepEqual(upstream.Status.Nameservers, shadow.Status.Nameservers) {
+ upstream.Status.Nameservers = append([]string(nil), shadow.Status.Nameservers...)
+ changed = true
+ }
+ }
+ }
+ }
+
+ // DomainRef: populate from upstream Domain object that matches spec.domainName.
+ var dlist networkingv1alpha.DomainList
+ if err := c.List(
+ ctx,
+ &dlist,
+ client.InNamespace(upstream.Namespace),
+ client.MatchingFieldsSelector{Selector: fields.OneTermEqualSelector("spec.domainName", upstream.Spec.DomainName)},
+ ); err != nil {
+ return err
+ }
+ var newRef *dnsv1alpha1.DomainRef
+ if len(dlist.Items) > 0 {
+ // Try to find one thats verified first else pick the first one.
+ indx := 0
+ for i, d := range dlist.Items {
+ if apimeta.IsStatusConditionTrue(d.Status.Conditions, networkingv1alpha.DomainConditionVerified) {
+ indx = i
+ break
+ }
+ }
+
+ d := dlist.Items[indx]
+ newRef = &dnsv1alpha1.DomainRef{
+ Name: d.Name,
+ Status: dnsv1alpha1.DomainRefStatus{
+ Nameservers: append([]networkingv1alpha.Nameserver(nil), d.Status.Nameservers...),
+ },
+ }
+ }
+ if !equality.Semantic.DeepEqual(upstream.Status.DomainRef, newRef) {
+ upstream.Status.DomainRef = newRef
+ changed = true
+ }
+
+ // Accepted: true only after nameservers have been retrieved from downstream
+ if len(upstream.Status.Nameservers) > 0 {
+ if apimeta.SetStatusCondition(&upstream.Status.Conditions, metav1.Condition{
+ Type: CondAccepted,
+ Status: metav1.ConditionTrue,
+ Reason: ReasonAccepted,
+ Message: "Nameservers retrieved from downstream",
+ ObservedGeneration: upstream.Generation,
+ LastTransitionTime: metav1.NewTime(time.Now()),
+ }) {
+ changed = true
+ }
+ } else {
+ if apimeta.SetStatusCondition(&upstream.Status.Conditions, metav1.Condition{
+ Type: CondAccepted,
+ Status: metav1.ConditionFalse,
+ Reason: ReasonPending,
+ Message: "Waiting for downstream nameservers",
+ ObservedGeneration: upstream.Generation,
+ LastTransitionTime: metav1.NewTime(time.Now()),
+ }) {
+ changed = true
+ }
+ }
+
+ var rsList dnsv1alpha1.DNSRecordSetList
+ if err := c.List(
+ ctx,
+ &rsList,
+ client.InNamespace(upstream.Namespace),
+ client.MatchingFieldsSelector{Selector: fields.OneTermEqualSelector("spec.dnsZoneRef.name", upstream.Name)},
+ ); err != nil {
+ return err
+ }
+
+ // Programmed: true only after default NS and SOA recordsets exist
+ programmed := false
+ if len(upstream.Status.Nameservers) > 0 {
+ haveSOA := false
+ haveNS := false
+ for i := range rsList.Items {
+ if rsList.Items[i].Spec.RecordType == dnsv1alpha1.RRTypeSOA {
+ haveSOA = true
+ }
+ if rsList.Items[i].Spec.RecordType == dnsv1alpha1.RRTypeNS {
+ haveNS = true
+ }
+ if haveSOA && haveNS {
+ break
+ }
+ }
+ programmed = haveSOA && haveNS
+
+ }
+ if programmed {
+ if apimeta.SetStatusCondition(&upstream.Status.Conditions, metav1.Condition{
+ Type: CondProgrammed,
+ Status: metav1.ConditionTrue,
+ Reason: ReasonProgrammed,
+ Message: "Default records ensured",
+ ObservedGeneration: upstream.Generation,
+ LastTransitionTime: metav1.NewTime(time.Now()),
+ }) {
+ changed = true
+ }
+ } else {
+ if apimeta.SetStatusCondition(&upstream.Status.Conditions, metav1.Condition{
+ Type: CondProgrammed,
+ Status: metav1.ConditionFalse,
+ Reason: ReasonPending,
+ Message: "Waiting for default records",
+ ObservedGeneration: upstream.Generation,
+ LastTransitionTime: metav1.NewTime(time.Now()),
+ }) {
+ changed = true
+ }
+ }
+
+ // RecordCount: compute number of DNSRecordSets referencing this zone and set status field
+ recordCount := 0
+ for i := range rsList.Items {
+ recordCount += len(rsList.Items[i].Spec.Records)
+ }
+ if upstream.Status.RecordCount != recordCount {
+ upstream.Status.RecordCount = recordCount
+ changed = true
+ }
+
+ if !changed {
+ return nil
+ }
+ if err := c.Status().Patch(ctx, upstream, client.MergeFrom(base)); err != nil {
+ return err
+ }
+ log.FromContext(ctx).Info("upstream zone status updated", "status", upstream.Status)
+ return nil
+}
+
+// ensureDomain guarantees that a Domain object with spec.domainName equal to the zone's domain name exists upstream.
+// If none exists, it creates one in the same namespace.
+func (r *DNSZoneReplicator) ensureDomain(ctx context.Context, c client.Client, upstream *dnsv1alpha1.DNSZone) error {
+ var existing networkingv1alpha.DomainList
+ if err := c.List(
+ ctx,
+ &existing,
+ client.InNamespace(upstream.Namespace),
+ client.MatchingFieldsSelector{Selector: fields.OneTermEqualSelector("spec.domainName", upstream.Spec.DomainName)},
+ ); err != nil {
+ return err
+ }
+ if len(existing.Items) > 0 {
+ log.FromContext(ctx).Info("Domain already exists for DNSZone; skipping create", "namespace", upstream.Namespace, "domainName", upstream.Spec.DomainName)
+ return nil
+ }
+ newDomain := networkingv1alpha.Domain{}
+ newDomain.SetNamespace(upstream.Namespace)
+ newDomain.SetName(upstream.Spec.DomainName)
+ newDomain.Spec.DomainName = upstream.Spec.DomainName
+ log.FromContext(ctx).Info("creating Domain for DNSZone (upstream)", "namespace", newDomain.Namespace, "domainName", newDomain.Spec.DomainName)
+ return c.Create(ctx, &newDomain)
+}
+
+// ---- Watches / mapping helpers --------------------------------------------
+
+func (r *DNSZoneReplicator) SetupWithManager(mgr mcmanager.Manager, downstreamCl cluster.Cluster) error {
+ r.mgr = mgr
+
+ // Register field indexes used by this controller when listing DNSRecordSets
+ if err := mgr.GetFieldIndexer().IndexField(context.Background(),
+ &dnsv1alpha1.DNSRecordSet{}, "spec.dnsZoneRef.name",
+ func(obj client.Object) []string {
+ rs := obj.(*dnsv1alpha1.DNSRecordSet)
+ return []string{rs.Spec.DNSZoneRef.Name}
+ },
+ ); err != nil {
+ return err
+ }
+ if err := mgr.GetFieldIndexer().IndexField(context.Background(),
+ &dnsv1alpha1.DNSRecordSet{}, "spec.recordType",
+ func(obj client.Object) []string {
+ rs := obj.(*dnsv1alpha1.DNSRecordSet)
+ return []string{string(rs.Spec.RecordType)}
+ },
+ ); err != nil {
+ return err
+ }
+ // Index DNSZone by spec.domainName to efficiently map Domains -> Zones across namespaces.
+ if err := mgr.GetFieldIndexer().IndexField(context.Background(),
+ &dnsv1alpha1.DNSZone{}, "spec.domainName",
+ func(obj client.Object) []string {
+ z := obj.(*dnsv1alpha1.DNSZone)
+ return []string{z.Spec.DomainName}
+ },
+ ); err != nil {
+ return err
+ }
+ // Index for Domain.spec.domainName to efficiently lookups by domain name.
+ if err := mgr.GetFieldIndexer().IndexField(context.Background(),
+ &networkingv1alpha.Domain{}, "spec.domainName",
+ func(obj client.Object) []string {
+ d := obj.(*networkingv1alpha.Domain)
+ return []string{d.Spec.DomainName}
+ },
+ ); err != nil {
+ return err
+ }
+
+ b := mcbuilder.ControllerManagedBy(mgr)
+
+ // Upstream watch
+ b = b.For(&dnsv1alpha1.DNSZone{}).Owns(&dnsv1alpha1.DNSRecordSet{})
+
+ // Watch upstream Domain objects and enqueue only if a matching DNSZone exists.
+ b = b.Watches(&networkingv1alpha.Domain{}, func(clusterName string, cl cluster.Cluster) handler.TypedEventHandler[client.Object, mcreconcile.Request] {
+ return handler.TypedEnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []mcreconcile.Request {
+ u, ok := obj.(*networkingv1alpha.Domain)
+ if !ok {
+ return nil
+ }
+ // Find upstream DNSZone(s) in the same namespace as the Domain.
+ var zones dnsv1alpha1.DNSZoneList
+ if err := cl.GetClient().List(ctx, &zones,
+ client.InNamespace(obj.GetNamespace()),
+ client.MatchingFieldsSelector{Selector: fields.OneTermEqualSelector("spec.domainName", u.Spec.DomainName)},
+ ); err != nil {
+ return nil
+ }
+ // TODO: ideally there is only ever one DNSZone with the same spec.domainName in the same namespace.
+ var reqs []mcreconcile.Request
+ for i := range zones.Items {
+ reqs = append(reqs, mcreconcile.Request{
+ ClusterName: clusterName,
+ Request: crreconcile.Request{NamespacedName: types.NamespacedName{Namespace: zones.Items[i].Namespace, Name: zones.Items[i].Name}},
+ })
+ }
+ return reqs
+ })
+ })
+
+ // Downstream watch (wake upstream on changes)
+ src := mcsource.TypedKind(
+ &dnsv1alpha1.DNSZone{},
+ downstreamclient.TypedEnqueueRequestForUpstreamOwner[*dnsv1alpha1.DNSZone](&dnsv1alpha1.DNSZone{}),
+ )
+ clusterSrc, err := src.ForCluster("", downstreamCl)
+ if err != nil {
+ return fmt.Errorf("failed to build downstream watch for %s: %w", dnsv1alpha1.GroupVersion.WithKind("DNSZone").String(), err)
+ }
+ b = b.WatchesRawSource(clusterSrc)
+
+ return b.Named("dnszone-replicator").Complete(r)
+}
diff --git a/internal/controller/dnszonediscovery_controller.go b/internal/controller/dnszonediscovery_controller.go
new file mode 100644
index 0000000..03b5f38
--- /dev/null
+++ b/internal/controller/dnszonediscovery_controller.go
@@ -0,0 +1,140 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+package controller
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "go.miloapis.com/dns-operator/internal/discovery"
+ apimeta "k8s.io/apimachinery/pkg/api/meta"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
+ logf "sigs.k8s.io/controller-runtime/pkg/log"
+ mcbuilder "sigs.k8s.io/multicluster-runtime/pkg/builder"
+ mcmanager "sigs.k8s.io/multicluster-runtime/pkg/manager"
+ mcreconcile "sigs.k8s.io/multicluster-runtime/pkg/reconcile"
+
+ dnsv1alpha1 "go.miloapis.com/dns-operator/api/v1alpha1"
+)
+
+// DNSZoneDiscoveryReplicator performs a one-shot discovery of records for a DNSZone on the upstream cluster.
+// It does not replicate downstream; it only updates upstream status.
+type DNSZoneDiscoveryReplicator struct {
+ mgr mcmanager.Manager
+}
+
+// +kubebuilder:rbac:groups=dns.networking.miloapis.com,resources=dnszonediscoveries,verbs=get;list;watch;update;patch
+// +kubebuilder:rbac:groups=dns.networking.miloapis.com,resources=dnszonediscoveries/status,verbs=get;update;patch
+// +kubebuilder:rbac:groups=dns.networking.miloapis.com,resources=dnszones,verbs=get;list;watch
+
+func (r *DNSZoneDiscoveryReplicator) Reconcile(ctx context.Context, req mcreconcile.Request) (ctrl.Result, error) {
+ logger := logf.FromContext(ctx).WithValues("cluster", req.ClusterName, "namespace", req.Namespace, "name", req.Name)
+ ctx = logf.IntoContext(ctx, logger)
+
+ upstreamCluster, err := r.mgr.GetCluster(ctx, req.ClusterName)
+ if err != nil {
+ return ctrl.Result{}, err
+ }
+
+ var dzd dnsv1alpha1.DNSZoneDiscovery
+ if err := upstreamCluster.GetClient().Get(ctx, req.NamespacedName, &dzd); err != nil {
+ return ctrl.Result{}, client.IgnoreNotFound(err)
+ }
+
+ // No lifecycle beyond initial discovery.
+ if !dzd.DeletionTimestamp.IsZero() {
+ return ctrl.Result{}, nil
+ }
+
+ // If already discovered, nothing to do at all.
+ if apimeta.IsStatusConditionTrue(dzd.Status.Conditions, CondDiscovered) {
+ return ctrl.Result{}, nil
+ }
+
+ // Fetch the referenced DNSZone
+ var zone dnsv1alpha1.DNSZone
+ if err := upstreamCluster.GetClient().Get(ctx, client.ObjectKey{Namespace: req.Namespace, Name: dzd.Spec.DNSZoneRef.Name}, &zone); err != nil {
+ base := dzd.DeepCopy()
+ msg := fmt.Sprintf("dnszone %q not found", dzd.Spec.DNSZoneRef.Name)
+ if apimeta.SetStatusCondition(&dzd.Status.Conditions, metav1.Condition{
+ Type: CondAccepted,
+ Status: metav1.ConditionFalse,
+ Reason: ReasonPending,
+ Message: msg,
+ ObservedGeneration: dzd.Generation,
+ LastTransitionTime: metav1.NewTime(time.Now()),
+ }) {
+ if err := upstreamCluster.GetClient().Status().Patch(ctx, &dzd, client.MergeFrom(base)); err != nil {
+ return ctrl.Result{}, err
+ }
+ }
+ return ctrl.Result{}, client.IgnoreNotFound(err)
+ }
+
+ // Ensure OwnerReference to referenced DNSZone early
+ if !metav1.IsControlledBy(&dzd, &zone) {
+ base := dzd.DeepCopy()
+ if err := controllerutil.SetControllerReference(&zone, &dzd, upstreamCluster.GetScheme()); err != nil {
+ return ctrl.Result{}, err
+ }
+ if err := upstreamCluster.GetClient().Patch(ctx, &dzd, client.MergeFrom(base)); err != nil {
+ return ctrl.Result{}, err
+ }
+ logger.Info("set controller OwnerReference to DNSZone", "dnsZone", zone.Name)
+ return ctrl.Result{}, nil
+ }
+
+ // Mark Accepted true if not already
+ if !apimeta.IsStatusConditionTrue(dzd.Status.Conditions, CondAccepted) {
+ base := dzd.DeepCopy()
+ if apimeta.SetStatusCondition(&dzd.Status.Conditions, metav1.Condition{
+ Type: CondAccepted,
+ Status: metav1.ConditionTrue,
+ Reason: ReasonAccepted,
+ Message: "Discovery Accepted",
+ ObservedGeneration: dzd.Generation,
+ LastTransitionTime: metav1.NewTime(time.Now()),
+ }) {
+ if err := upstreamCluster.GetClient().Status().Patch(ctx, &dzd, client.MergeFrom(base)); err != nil {
+ return ctrl.Result{}, err
+ }
+ }
+ }
+
+ // Perform discovery (one-shot)
+ recordSets, err := discovery.DiscoverZoneRecords(ctx, zone.Spec.DomainName)
+ if err != nil {
+ logger.Error(err, "discovery failed; will retry", "zone", zone.Spec.DomainName)
+ return ctrl.Result{}, err
+ }
+
+ // Log how many records were discovered
+ logger.Info("discovered records", "count", len(recordSets))
+
+ base := dzd.DeepCopy()
+ dzd.Status.RecordSets = recordSets
+ apimeta.SetStatusCondition(&dzd.Status.Conditions, metav1.Condition{
+ Type: CondDiscovered,
+ Status: metav1.ConditionTrue,
+ Reason: ReasonDiscovered,
+ Message: "Zone Records Discovered",
+ ObservedGeneration: dzd.Generation,
+ LastTransitionTime: metav1.NewTime(time.Now()),
+ })
+ if err := upstreamCluster.GetClient().Status().Patch(ctx, &dzd, client.MergeFrom(base)); err != nil {
+ return ctrl.Result{}, err
+ }
+ return ctrl.Result{}, nil
+}
+
+// SetupWithManager wires the controller with the multicluster manager.
+func (r *DNSZoneDiscoveryReplicator) SetupWithManager(mgr mcmanager.Manager) error {
+ r.mgr = mgr
+ return mcbuilder.ControllerManagedBy(mgr).
+ For(&dnsv1alpha1.DNSZoneDiscovery{}).
+ Named("dnszonediscovery-replicator").
+ Complete(r)
+}
diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go
index ef721c8..a665518 100644
--- a/internal/controller/suite_test.go
+++ b/internal/controller/suite_test.go
@@ -1,18 +1,4 @@
-/*
-Copyright 2025.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
+// SPDX-License-Identifier: AGPL-3.0-only
package controller
diff --git a/internal/discovery/discovery.go b/internal/discovery/discovery.go
new file mode 100644
index 0000000..9a93c32
--- /dev/null
+++ b/internal/discovery/discovery.go
@@ -0,0 +1,157 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+package discovery
+
+import (
+ "strings"
+
+ "github.com/miekg/dns"
+ dnsv1alpha1 "go.miloapis.com/dns-operator/api/v1alpha1"
+)
+
+// mapAnswersToEntries converts a slice of dns.RR answers of a single RR type into
+// a list of RecordEntry where each entry represents exactly one owner + one
+// value for the given RecordType.
+//
+// It sets typed fields on RecordEntry wherever supported by the API types.
+// Unsupported/unknown types (including PTR, since there is no typed PTR field)
+// are skipped.
+func mapAnswersToEntries(zoneFQDN string, answers []dns.RR) []dnsv1alpha1.RecordEntry {
+ out := make([]dnsv1alpha1.RecordEntry, 0, len(answers))
+
+ for _, rr := range answers {
+ name := ownerToRelative(rr.Header().Name, zoneFQDN)
+ ttl := int64(rr.Header().Ttl)
+
+ entry := dnsv1alpha1.RecordEntry{
+ Name: name,
+ TTL: &ttl,
+ }
+
+ switch r := rr.(type) {
+ case *dns.A:
+ entry.A = &dnsv1alpha1.ARecordSpec{
+ Content: r.A.String(),
+ }
+
+ case *dns.AAAA:
+ entry.AAAA = &dnsv1alpha1.AAAARecordSpec{
+ Content: r.AAAA.String(),
+ }
+
+ case *dns.NS:
+ entry.NS = &dnsv1alpha1.NSRecordSpec{
+ Content: ensureTrailingDot(r.Ns),
+ }
+
+ case *dns.TXT:
+ // Join fragments into a single logical TXT string
+ entry.TXT = &dnsv1alpha1.TXTRecordSpec{
+ Content: strings.Join(r.Txt, ""),
+ }
+
+ case *dns.CNAME:
+ entry.CNAME = &dnsv1alpha1.CNAMERecordSpec{
+ Content: ensureTrailingDot(r.Target),
+ }
+
+ case *dns.SOA:
+ entry.SOA = &dnsv1alpha1.SOARecordSpec{
+ MName: ensureTrailingDot(r.Ns),
+ RName: ensureTrailingDot(r.Mbox),
+ Serial: r.Serial,
+ Refresh: r.Refresh,
+ Retry: r.Retry,
+ Expire: r.Expire,
+ TTL: r.Minttl,
+ }
+
+ case *dns.CAA:
+ entry.CAA = &dnsv1alpha1.CAARecordSpec{
+ Flag: r.Flag,
+ Tag: r.Tag,
+ Value: r.Value,
+ }
+
+ case *dns.MX:
+ entry.MX = &dnsv1alpha1.MXRecordSpec{
+ Preference: r.Preference,
+ Exchange: ensureTrailingDot(r.Mx),
+ }
+
+ case *dns.SRV:
+ entry.SRV = &dnsv1alpha1.SRVRecordSpec{
+ Priority: r.Priority,
+ Weight: r.Weight,
+ Port: r.Port,
+ Target: ensureTrailingDot(r.Target),
+ }
+
+ case *dns.TLSA:
+ entry.TLSA = &dnsv1alpha1.TLSARecordSpec{
+ Usage: r.Usage,
+ Selector: r.Selector,
+ MatchingType: r.MatchingType,
+ CertData: r.Certificate,
+ }
+
+ case *dns.HTTPS:
+ entry.HTTPS = &dnsv1alpha1.HTTPSRecordSpec{
+ Priority: r.Priority,
+ Target: ensureTrailingDot(r.Target),
+ Params: svcbParamsToMap(r.Value),
+ }
+
+ case *dns.SVCB:
+ entry.SVCB = &dnsv1alpha1.HTTPSRecordSpec{
+ Priority: r.Priority,
+ Target: ensureTrailingDot(r.Target),
+ Params: svcbParamsToMap(r.Value),
+ }
+
+ default:
+ // No typed representation for this RR type (e.g. PTR) -> skip.
+ continue
+ }
+
+ out = append(out, entry)
+ }
+
+ return out
+}
+
+func ensureTrailingDot(s string) string {
+ if s == "" || strings.HasSuffix(s, ".") {
+ return s
+ }
+ return s + "."
+}
+
+func ownerToRelative(owner, zoneFQDN string) string {
+ o := strings.TrimSuffix(owner, ".")
+ z := strings.TrimSuffix(zoneFQDN, ".")
+ if strings.EqualFold(o, z) {
+ return "@"
+ }
+ if strings.HasSuffix(strings.ToLower(o), strings.ToLower("."+z)) {
+ return strings.TrimSuffix(o, "."+z)
+ }
+ return o
+}
+
+func svcbParamsToMap(values []dns.SVCBKeyValue) map[string]string {
+ if len(values) == 0 {
+ return nil
+ }
+ out := make(map[string]string, len(values))
+ for _, v := range values {
+ // v.String() renders "key=value" or "flag" (no value)
+ kv := v.String()
+ parts := strings.SplitN(kv, "=", 2)
+ if len(parts) == 2 {
+ out[parts[0]] = parts[1]
+ } else if len(parts) == 1 {
+ out[parts[0]] = ""
+ }
+ }
+ return out
+}
diff --git a/internal/discovery/resolver.go b/internal/discovery/resolver.go
new file mode 100644
index 0000000..9815937
--- /dev/null
+++ b/internal/discovery/resolver.go
@@ -0,0 +1,116 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+package discovery
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/miekg/dns"
+ "github.com/projectdiscovery/dnsx/libs/dnsx"
+ dnsv1alpha1 "go.miloapis.com/dns-operator/api/v1alpha1"
+)
+
+// DiscoverZoneRecords performs best-effort discovery of common RR types for the given domain
+// and returns RecordSets grouped by RecordType, with typed fields populated where available.
+func DiscoverZoneRecords(ctx context.Context, domain string) ([]dnsv1alpha1.DiscoveredRecordSet, error) {
+ fmt.Printf("starting discovery for domain=%q\n", domain)
+ // Exclude NS and SOA per requirements.
+ options := dnsx.DefaultOptions
+ qtypes := []uint16{
+ dns.TypeA,
+ dns.TypeAAAA,
+ dns.TypeCNAME,
+ dns.TypeTXT,
+ dns.TypeMX,
+ dns.TypeSRV,
+ dns.TypeCAA,
+ dns.TypeTLSA,
+ dns.TypeHTTPS,
+ dns.TypeSVCB,
+ }
+ options.QuestionTypes = qtypes
+ options.QueryAll = true
+
+ fmt.Println("creating dnsx client")
+ client, err := dnsx.New(options)
+ if err != nil {
+ return nil, err
+ }
+ fmt.Println("querying multiple record types")
+ resp, err := client.QueryMultiple(domain)
+ if err != nil {
+ return nil, err
+ }
+ if resp == nil {
+ return nil, fmt.Errorf("dnsx returned nil response")
+ }
+ // Print quick summary for debugging
+ fmt.Printf("resolver status=%s answers=%d types=%v\n", resp.StatusCode, len(resp.AllRecords), qtypes)
+
+ // Build RRs from textual records since QueryMultiple does not populate RawResp
+ typeToRRs := make(map[uint16][]dns.RR)
+ for _, rec := range resp.AllRecords {
+ rr, perr := dns.NewRR(rec)
+ if perr != nil || rr == nil {
+ continue
+ }
+ rt := rr.Header().Rrtype
+ typeToRRs[rt] = append(typeToRRs[rt], rr)
+ }
+
+ typeToEntries := make(map[dnsv1alpha1.RRType][]dnsv1alpha1.RecordEntry)
+ for _, qt := range qtypes {
+ answers := typeToRRs[qt]
+ if len(answers) == 0 {
+ continue
+ }
+ entries := mapAnswersToEntries(domain, answers)
+ if len(entries) == 0 {
+
+ continue
+ }
+ if rt, ok := mapQtypeToRRType(qt); ok {
+ typeToEntries[rt] = append(typeToEntries[rt], entries...)
+ }
+ }
+
+ out := make([]dnsv1alpha1.DiscoveredRecordSet, 0, len(typeToEntries))
+ for rt, recs := range typeToEntries {
+ out = append(out, dnsv1alpha1.DiscoveredRecordSet{
+ RecordType: rt,
+ Records: recs,
+ })
+ }
+ return out, nil
+}
+
+func mapQtypeToRRType(qt uint16) (dnsv1alpha1.RRType, bool) {
+ switch qt {
+ case dns.TypeA:
+ return dnsv1alpha1.RRTypeA, true
+ case dns.TypeAAAA:
+ return dnsv1alpha1.RRTypeAAAA, true
+ case dns.TypeCNAME:
+ return dnsv1alpha1.RRTypeCNAME, true
+ case dns.TypeTXT:
+ return dnsv1alpha1.RRTypeTXT, true
+ case dns.TypeMX:
+ return dnsv1alpha1.RRTypeMX, true
+ case dns.TypeSRV:
+ return dnsv1alpha1.RRTypeSRV, true
+ case dns.TypeNS:
+ return dnsv1alpha1.RRTypeNS, true
+ case dns.TypeSOA:
+ return dnsv1alpha1.RRTypeSOA, true
+ case dns.TypeCAA:
+ return dnsv1alpha1.RRTypeCAA, true
+ case dns.TypeTLSA:
+ return dnsv1alpha1.RRTypeTLSA, true
+ case dns.TypeHTTPS:
+ return dnsv1alpha1.RRTypeHTTPS, true
+ case dns.TypeSVCB:
+ return dnsv1alpha1.RRTypeSVCB, true
+ default:
+ return "", false
+ }
+}
diff --git a/internal/downstreamclient/enqueue_upstream_owner.go b/internal/downstreamclient/enqueue_upstream_owner.go
new file mode 100644
index 0000000..5ef257a
--- /dev/null
+++ b/internal/downstreamclient/enqueue_upstream_owner.go
@@ -0,0 +1,122 @@
+package downstreamclient
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ "k8s.io/apimachinery/pkg/types"
+ "k8s.io/client-go/util/workqueue"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/cluster"
+ "sigs.k8s.io/controller-runtime/pkg/event"
+ "sigs.k8s.io/controller-runtime/pkg/handler"
+ "sigs.k8s.io/controller-runtime/pkg/reconcile"
+ mchandler "sigs.k8s.io/multicluster-runtime/pkg/handler"
+ mcreconcile "sigs.k8s.io/multicluster-runtime/pkg/reconcile"
+)
+
+var _ mchandler.EventHandler = &enqueueRequestForOwner[client.Object]{}
+
+type empty struct{}
+
+// TypedEnqueueRequestForUpstreamOwner enqueues Requests for the upstream Owners of an object.
+//
+// This handler depends on the `compute.datumapis.com/upstream-namespace` label
+// to exist on the resource for the event.
+func TypedEnqueueRequestForUpstreamOwner[object client.Object](ownerType client.Object) mchandler.TypedEventHandlerFunc[object, mcreconcile.Request] {
+
+ return func(clusterName string, cl cluster.Cluster) handler.TypedEventHandler[object, mcreconcile.Request] {
+ e := &enqueueRequestForOwner[object]{
+ ownerType: ownerType,
+ }
+ if err := e.parseOwnerTypeGroupKind(cl.GetScheme()); err != nil {
+ panic(err)
+ }
+
+ return e
+ }
+}
+
+type enqueueRequestForOwner[object client.Object] struct {
+ // ownerType is the type of the Owner object to look for in OwnerReferences. Only Group and Kind are compared.
+ ownerType runtime.Object
+
+ // groupKind is the cached Group and Kind from OwnerType
+ groupKind schema.GroupKind
+}
+
+// Create implements EventHandler.
+func (e *enqueueRequestForOwner[object]) Create(ctx context.Context, evt event.TypedCreateEvent[object], q workqueue.TypedRateLimitingInterface[mcreconcile.Request]) {
+ reqs := map[mcreconcile.Request]empty{}
+ e.getOwnerReconcileRequest(evt.Object, reqs)
+ for req := range reqs {
+ q.Add(req)
+ }
+}
+
+// Update implements EventHandler.
+func (e *enqueueRequestForOwner[object]) Update(ctx context.Context, evt event.TypedUpdateEvent[object], q workqueue.TypedRateLimitingInterface[mcreconcile.Request]) {
+ reqs := map[mcreconcile.Request]empty{}
+ e.getOwnerReconcileRequest(evt.ObjectOld, reqs)
+ e.getOwnerReconcileRequest(evt.ObjectNew, reqs)
+ for req := range reqs {
+ q.Add(req)
+ }
+}
+
+// Delete implements EventHandler.
+func (e *enqueueRequestForOwner[object]) Delete(ctx context.Context, evt event.TypedDeleteEvent[object], q workqueue.TypedRateLimitingInterface[mcreconcile.Request]) {
+ reqs := map[mcreconcile.Request]empty{}
+ e.getOwnerReconcileRequest(evt.Object, reqs)
+ for req := range reqs {
+ q.Add(req)
+ }
+}
+
+// Generic implements EventHandler.
+func (e *enqueueRequestForOwner[object]) Generic(ctx context.Context, evt event.TypedGenericEvent[object], q workqueue.TypedRateLimitingInterface[mcreconcile.Request]) {
+ reqs := map[mcreconcile.Request]empty{}
+ e.getOwnerReconcileRequest(evt.Object, reqs)
+ for req := range reqs {
+ q.Add(req)
+ }
+}
+
+// parseOwnerTypeGroupKind parses the OwnerType into a Group and Kind and caches the result. Returns false
+// if the OwnerType could not be parsed using the scheme.
+func (e *enqueueRequestForOwner[object]) parseOwnerTypeGroupKind(scheme *runtime.Scheme) error {
+ // Get the kinds of the type
+ kinds, _, err := scheme.ObjectKinds(e.ownerType)
+ if err != nil {
+ return err
+ }
+ // Expect only 1 kind. If there is more than one kind this is probably an edge case such as ListOptions.
+ if len(kinds) != 1 {
+ return fmt.Errorf("expected exactly 1 kind for OwnerType %T, but found %s kinds", e.ownerType, kinds)
+ }
+ // Cache the Group and Kind for the OwnerType
+ e.groupKind = schema.GroupKind{Group: kinds[0].Group, Kind: kinds[0].Kind}
+ return nil
+}
+
+// getOwnerReconcileRequest looks at object and builds a map of reconcile.Request to reconcile
+// owners of object that match e.OwnerType.
+func (e *enqueueRequestForOwner[object]) getOwnerReconcileRequest(obj metav1.Object, result map[mcreconcile.Request]empty) {
+ labels := obj.GetLabels()
+ if labels[UpstreamOwnerKindLabel] == e.groupKind.Kind && labels[UpstreamOwnerGroupLabel] == e.groupKind.Group {
+ request := mcreconcile.Request{
+ Request: reconcile.Request{
+ NamespacedName: types.NamespacedName{
+ Name: labels[UpstreamOwnerNameLabel],
+ Namespace: labels[UpstreamOwnerNamespaceLabel],
+ },
+ },
+ ClusterName: strings.TrimPrefix(strings.ReplaceAll(labels[UpstreamOwnerClusterNameLabel], "_", "/"), "cluster-"),
+ }
+ result[request] = empty{}
+ }
+}
diff --git a/internal/downstreamclient/mappednamespace.go b/internal/downstreamclient/mappednamespace.go
new file mode 100644
index 0000000..6e779ee
--- /dev/null
+++ b/internal/downstreamclient/mappednamespace.go
@@ -0,0 +1,281 @@
+package downstreamclient
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ corev1 "k8s.io/api/core/v1"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ "k8s.io/apimachinery/pkg/api/meta"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/client/apiutil"
+ "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
+)
+
+// +kubebuilder:rbac:groups=core,resources=namespaces,verbs=get;list;watch
+
+var _ ResourceStrategy = &mappedNamespaceResourceStrategy{}
+
+type mappedNamespaceResourceStrategy struct {
+ upstreamClusterName string
+ upstreamClient client.Client
+ downstreamClient client.Client
+}
+
+func NewMappedNamespaceResourceStrategy(
+ upstreamClusterName string,
+ upstreamClient client.Client,
+ downstreamClient client.Client,
+) ResourceStrategy {
+ return &mappedNamespaceResourceStrategy{
+ upstreamClusterName: upstreamClusterName,
+ upstreamClient: upstreamClient,
+ downstreamClient: downstreamClient,
+ }
+}
+
+func (c *mappedNamespaceResourceStrategy) GetClient() client.Client {
+ return &mappedNamespaceClient{
+ client: c.downstreamClient,
+ resourceStrategy: c,
+ }
+}
+
+func (c *mappedNamespaceResourceStrategy) ObjectMetaFromUpstreamObject(ctx context.Context, obj metav1.Object) (metav1.ObjectMeta, error) {
+ downstreamNamespaceName, err := c.getDownstreamNamespaceName(ctx, obj)
+ if err != nil {
+ return metav1.ObjectMeta{}, fmt.Errorf("failed to get downstream namespace name: %w", err)
+ }
+
+ return metav1.ObjectMeta{
+ Name: obj.GetName(),
+ Namespace: downstreamNamespaceName,
+ Labels: map[string]string{
+ UpstreamOwnerNamespaceLabel: obj.GetNamespace(),
+ },
+ }, nil
+}
+
+func (c *mappedNamespaceResourceStrategy) getUpstreamNamespace(ctx context.Context, obj metav1.Object) (*corev1.Namespace, error) {
+ namespace := &corev1.Namespace{}
+
+ if obj == nil {
+ return nil, fmt.Errorf("object is nil")
+ }
+ if c.upstreamClient == nil {
+ return nil, fmt.Errorf("upstream client is nil")
+ }
+ if err := c.upstreamClient.Get(ctx, client.ObjectKey{Name: obj.GetNamespace()}, namespace); err != nil {
+ return nil, fmt.Errorf("failed to get upstream namespace: %w", err)
+ }
+
+ return namespace, nil
+}
+
+func (c *mappedNamespaceResourceStrategy) getDownstreamNamespaceName(ctx context.Context, obj metav1.Object) (string, error) {
+ namespace, err := c.getUpstreamNamespace(ctx, obj)
+ if err != nil {
+ return "", fmt.Errorf("failed to get downstream namespace: %w", err)
+ }
+
+ return fmt.Sprintf("ns-%s", namespace.UID), nil
+}
+
+func (c *mappedNamespaceResourceStrategy) ensureDownstreamNamespace(ctx context.Context, obj metav1.Object) (*corev1.Namespace, error) {
+ downstreamNamespace := &corev1.Namespace{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: obj.GetNamespace(),
+ },
+ }
+
+ _, err := controllerutil.CreateOrUpdate(ctx, c.downstreamClient, downstreamNamespace, func() error {
+ if downstreamNamespace.Labels == nil {
+ downstreamNamespace.Labels = make(map[string]string)
+ }
+
+ downstreamNamespace.Labels[UpstreamOwnerClusterNameLabel] = fmt.Sprintf("cluster-%s", strings.ReplaceAll(c.upstreamClusterName, "/", "_"))
+
+ labels := obj.GetLabels()
+ if v, ok := labels[UpstreamOwnerNamespaceLabel]; ok {
+ downstreamNamespace.Labels[UpstreamOwnerNamespaceLabel] = v
+ }
+
+ return nil
+ })
+ if err != nil {
+ return nil, fmt.Errorf("failed to ensure downstream namespace: %w", err)
+ }
+
+ return downstreamNamespace, nil
+}
+
+const (
+ UpstreamOwnerClusterNameLabel = "meta.datumapis.com/upstream-cluster-name"
+ UpstreamOwnerGroupLabel = "meta.datumapis.com/upstream-group"
+ UpstreamOwnerKindLabel = "meta.datumapis.com/upstream-kind"
+ UpstreamOwnerNameLabel = "meta.datumapis.com/upstream-name"
+ UpstreamOwnerNamespaceLabel = "meta.datumapis.com/upstream-namespace"
+)
+
+func (c *mappedNamespaceResourceStrategy) SetControllerReference(ctx context.Context, owner, controlled metav1.Object, opts ...controllerutil.OwnerReferenceOption) error {
+ // TODO(jreese) add owner validation
+
+ if owner.GetNamespace() == "" || controlled.GetNamespace() == "" {
+ return fmt.Errorf("cluster scoped resource controllers are not supported")
+ }
+
+ // For simplicity, we use a ConfigMap for an anchor. This may change to a
+ // separate type in the future if ConfigMap bloat causes an issue in caches.
+
+ gvk, err := apiutil.GVKForObject(owner.(runtime.Object), c.upstreamClient.Scheme())
+ if err != nil {
+ return err
+ }
+
+ anchorName := fmt.Sprintf("anchor-%s", owner.GetUID())
+
+ anchorLabels := map[string]string{
+ UpstreamOwnerClusterNameLabel: fmt.Sprintf("cluster-%s", strings.ReplaceAll(c.upstreamClusterName, "/", "_")),
+ UpstreamOwnerGroupLabel: gvk.Group,
+ UpstreamOwnerKindLabel: gvk.Kind,
+ UpstreamOwnerNameLabel: owner.GetName(),
+ UpstreamOwnerNamespaceLabel: owner.GetNamespace(),
+ }
+
+ downstreamClient := c.GetClient()
+
+ var anchorConfigMap corev1.ConfigMap
+ if err := downstreamClient.Get(ctx, client.ObjectKey{Namespace: controlled.GetNamespace(), Name: anchorName}, &anchorConfigMap); client.IgnoreNotFound(err) != nil {
+ return fmt.Errorf("failed listing configmaps: %w", err)
+ }
+
+ if anchorConfigMap.CreationTimestamp.IsZero() {
+ anchorConfigMap.Name = anchorName
+ anchorConfigMap.Labels = anchorLabels
+ anchorConfigMap.Namespace = controlled.GetNamespace()
+ if err := downstreamClient.Create(ctx, &anchorConfigMap); err != nil {
+ return fmt.Errorf("failed creating anchor configmap: %w", err)
+ }
+ }
+
+ if err := controllerutil.SetOwnerReference(&anchorConfigMap, controlled, downstreamClient.Scheme(), opts...); err != nil {
+ return fmt.Errorf("failed setting anchor owner reference: %w", err)
+ }
+
+ labels := controlled.GetLabels()
+ if labels == nil {
+ labels = map[string]string{}
+ }
+
+ labels[UpstreamOwnerClusterNameLabel] = anchorLabels[UpstreamOwnerClusterNameLabel]
+ labels[UpstreamOwnerGroupLabel] = anchorLabels[UpstreamOwnerGroupLabel]
+ labels[UpstreamOwnerKindLabel] = anchorLabels[UpstreamOwnerKindLabel]
+ labels[UpstreamOwnerNameLabel] = anchorLabels[UpstreamOwnerNameLabel]
+ labels[UpstreamOwnerNamespaceLabel] = anchorLabels[UpstreamOwnerNamespaceLabel]
+ controlled.SetLabels(labels)
+
+ return nil
+}
+
+func (c *mappedNamespaceResourceStrategy) SetOwnerReference(ctx context.Context, owner, object metav1.Object, opts ...controllerutil.OwnerReferenceOption) error {
+ return controllerutil.SetOwnerReference(owner, object, c.downstreamClient.Scheme(), opts...)
+}
+
+// DeleteAnchorForObject will delete the anchor configmap associated with the
+// provided owner, which will help drive GC of other entities.
+func (c *mappedNamespaceResourceStrategy) DeleteAnchorForObject(
+ ctx context.Context,
+ owner client.Object,
+) error {
+
+ anchorName := fmt.Sprintf("anchor-%s", owner.GetUID())
+
+ downstreamObjectMeta, err := c.ObjectMetaFromUpstreamObject(ctx, owner)
+ if err != nil {
+ return fmt.Errorf("failed to get downstream object metadata: %w", err)
+ }
+
+ downstreamClient := c.GetClient()
+
+ var configMap corev1.ConfigMap
+ if err := downstreamClient.Get(ctx, client.ObjectKey{Namespace: downstreamObjectMeta.Namespace, Name: anchorName}, &configMap); err != nil {
+ if apierrors.IsNotFound(err) {
+ return nil
+ }
+ return fmt.Errorf("failed listing configmaps: %w", err)
+ }
+
+ return downstreamClient.Delete(ctx, &configMap)
+}
+
+var _ client.Client = &mappedNamespaceClient{}
+
+type mappedNamespaceClient struct {
+ client client.Client
+ resourceStrategy *mappedNamespaceResourceStrategy
+}
+
+func (c *mappedNamespaceClient) Apply(ctx context.Context, obj runtime.ApplyConfiguration, opts ...client.ApplyOption) error {
+ return c.client.Apply(ctx, obj, opts...)
+}
+
+func (c *mappedNamespaceClient) Create(ctx context.Context, obj client.Object, opts ...client.CreateOption) error {
+ _, err := c.resourceStrategy.ensureDownstreamNamespace(ctx, obj)
+ if err != nil {
+ return fmt.Errorf("failed to ensure downstream namespace: %w", err)
+ }
+
+ return c.client.Create(ctx, obj, opts...)
+}
+
+func (c *mappedNamespaceClient) Delete(ctx context.Context, obj client.Object, opts ...client.DeleteOption) error {
+ return c.client.Delete(ctx, obj, opts...)
+}
+
+func (c *mappedNamespaceClient) DeleteAllOf(ctx context.Context, obj client.Object, opts ...client.DeleteAllOfOption) error {
+ return c.client.DeleteAllOf(ctx, obj, opts...)
+}
+
+func (c *mappedNamespaceClient) Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error {
+ return c.client.Get(ctx, key, obj, opts...)
+}
+
+func (c *mappedNamespaceClient) List(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error {
+ return c.client.List(ctx, list, opts...)
+}
+
+func (c *mappedNamespaceClient) Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.PatchOption) error {
+ return c.client.Patch(ctx, obj, patch, opts...)
+}
+
+func (c *mappedNamespaceClient) Update(ctx context.Context, obj client.Object, opts ...client.UpdateOption) error {
+ return c.client.Update(ctx, obj, opts...)
+}
+
+func (c *mappedNamespaceClient) GroupVersionKindFor(obj runtime.Object) (schema.GroupVersionKind, error) {
+ return c.client.GroupVersionKindFor(obj)
+}
+
+func (c *mappedNamespaceClient) IsObjectNamespaced(obj runtime.Object) (bool, error) {
+ return c.client.IsObjectNamespaced(obj)
+}
+
+func (c *mappedNamespaceClient) Scheme() *runtime.Scheme {
+ return c.client.Scheme()
+}
+
+func (c *mappedNamespaceClient) RESTMapper() meta.RESTMapper {
+ return c.client.RESTMapper()
+}
+
+func (c *mappedNamespaceClient) Status() client.SubResourceWriter {
+ return c.client.Status()
+}
+
+func (c *mappedNamespaceClient) SubResource(subResource string) client.SubResourceClient {
+ return c.client.SubResource(subResource)
+}
diff --git a/internal/downstreamclient/resourcestrategy.go b/internal/downstreamclient/resourcestrategy.go
new file mode 100644
index 0000000..cd51376
--- /dev/null
+++ b/internal/downstreamclient/resourcestrategy.go
@@ -0,0 +1,36 @@
+package downstreamclient
+
+import (
+ "context"
+
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
+)
+
+// ResourceStrategy is an interface that is used to reduce the burden of
+// writing controllers that need to write to downstream resources that are
+// artifacts of upstream resources and may need to be placed in downstream
+// clusters.
+//
+// One implementation could just return the client from the cluster that was
+// passed in. Another could return a client that ends up rewriting namespaces
+// in a way that you can target a single API server and not have conflicts.
+// Another could return a client that aligns each source cluster with a target
+// cluster, which could be a whole API server, or something like a KCP
+// workspace, and doesn't do any namespace/name rewriting.
+//
+// This way, the controller can be written as if it's putting resources into
+// the same namespace as the upstream resource, but that doesn't mean it'll
+// land in the same place as that resource.
+type ResourceStrategy interface {
+ GetClient() client.Client
+
+ // ObjectMetaFromUpstreamObject returns an ObjectMeta struct with Namespace and
+ // Name fields populated for the downstream resource.
+ ObjectMetaFromUpstreamObject(context.Context, metav1.Object) (metav1.ObjectMeta, error)
+
+ SetControllerReference(context.Context, metav1.Object, metav1.Object, ...controllerutil.OwnerReferenceOption) error
+ SetOwnerReference(context.Context, metav1.Object, metav1.Object, ...controllerutil.OwnerReferenceOption) error
+ DeleteAnchorForObject(ctx context.Context, owner client.Object) error
+}
diff --git a/internal/downstreamclient/sameclusterandnamespace.go b/internal/downstreamclient/sameclusterandnamespace.go
new file mode 100644
index 0000000..34eb9f6
--- /dev/null
+++ b/internal/downstreamclient/sameclusterandnamespace.go
@@ -0,0 +1,61 @@
+package downstreamclient
+
+import (
+ "context"
+ "crypto/md5"
+ "fmt"
+
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
+)
+
+var _ ResourceStrategy = &sameClusterAndNamespaceResourceStrategy{}
+
+type sameClusterAndNamespaceResourceStrategy struct {
+ client client.Client
+}
+
+func NewSameClusterAndNamespaceResourceStrategy(c client.Client) ResourceStrategy {
+ return &sameClusterAndNamespaceResourceStrategy{
+ client: c,
+ }
+}
+
+func (c *sameClusterAndNamespaceResourceStrategy) GetClient() client.Client {
+ return c.client
+}
+
+// ObjectMetaFromObject returns a name derived from the input object's name, where
+// the value is the first 188 characters of the input object's name, suffixed by
+// the sha256 hash of the full input object's name.
+func (c *sameClusterAndNamespaceResourceStrategy) ObjectMetaFromUpstreamObject(ctx context.Context, obj metav1.Object) (metav1.ObjectMeta, error) {
+ upstreamName := obj.GetName()
+
+ // MD5 produces 32 hex characters
+ hash := md5.Sum([]byte(upstreamName))
+
+ // Reserve 33 chars for hash and hyphen (32 for MD5 + 1 for hyphen)
+ // This leaves 30 chars for the prefix
+ maxPrefixLen := 30
+ if len(upstreamName) > maxPrefixLen {
+ upstreamName = upstreamName[0:maxPrefixLen]
+ }
+
+ return metav1.ObjectMeta{
+ Namespace: obj.GetNamespace(),
+ Name: fmt.Sprintf("%s-%x", upstreamName, hash),
+ }, nil
+}
+
+func (c *sameClusterAndNamespaceResourceStrategy) SetControllerReference(ctx context.Context, owner, controlled metav1.Object, opts ...controllerutil.OwnerReferenceOption) error {
+ return controllerutil.SetControllerReference(owner, controlled, c.GetClient().Scheme(), opts...)
+}
+
+func (c *sameClusterAndNamespaceResourceStrategy) SetOwnerReference(ctx context.Context, owner, object metav1.Object, opts ...controllerutil.OwnerReferenceOption) error {
+ return controllerutil.SetOwnerReference(owner, object, c.GetClient().Scheme(), opts...)
+}
+
+func (c *sameClusterAndNamespaceResourceStrategy) DeleteAnchorForObject(ctx context.Context, owner client.Object) error {
+ return nil
+}
diff --git a/internal/pdns/client.go b/internal/pdns/client.go
new file mode 100644
index 0000000..63c03c1
--- /dev/null
+++ b/internal/pdns/client.go
@@ -0,0 +1,679 @@
+package pdns
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "sort"
+ "strings"
+ "time"
+
+ dnsv1alpha1 "go.miloapis.com/dns-operator/api/v1alpha1"
+)
+
+type Client struct {
+ BaseURL string
+ APIKey string
+ HTTP *http.Client
+}
+
+func NewClient(baseURL, apiKey string) *Client {
+ return &Client{
+ BaseURL: baseURL,
+ APIKey: apiKey,
+ HTTP: &http.Client{Timeout: 10 * time.Second},
+ }
+}
+
+type pdnsAPIError struct {
+ Status int
+ Body string
+}
+
+func (e *pdnsAPIError) Error() string {
+ if e.Body != "" {
+ return fmt.Sprintf("status %d: %s", e.Status, e.Body)
+ }
+ return fmt.Sprintf("error: status %d", e.Status)
+}
+
+func readRespBody(resp *http.Response, max int64) string {
+ if resp == nil || resp.Body == nil {
+ return ""
+ }
+ defer func() { _ = resp.Body.Close() }()
+ // don't blow up logs; cap at e.g. 16KB
+ if max <= 0 {
+ max = 16 << 10 // 16 KiB
+ }
+ b, _ := io.ReadAll(io.LimitReader(resp.Body, max))
+ return strings.TrimSpace(string(b))
+}
+
+type createZoneRequest struct {
+ Name string `json:"name"`
+ Kind string `json:"kind"` // "Native" or "Master"
+ Nameservers []string `json:"nameservers"`
+}
+
+// CreateZone creates an authoritative zone if it does not exist.
+func (c *Client) CreateZone(ctx context.Context, zone string, nameservers []string) error {
+ // PDNS expects absolute nameserver hostnames (trailing dot)
+ nsAbs := make([]string, 0, len(nameservers))
+ for _, ns := range nameservers {
+ if ns == "" {
+ continue
+ }
+ if ns[len(ns)-1] != '.' {
+ ns += "."
+ }
+ nsAbs = append(nsAbs, ns)
+ }
+ payload := createZoneRequest{
+ Name: zone + ".",
+ Kind: "Native",
+ Nameservers: nsAbs,
+ }
+ body, _ := json.Marshal(payload)
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/api/v1/servers/localhost/zones", bytes.NewReader(body))
+ if err != nil {
+ return err
+ }
+ req.Header.Set("X-API-Key", c.APIKey)
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := c.HTTP.Do(req)
+ if err != nil {
+ return err
+ }
+ defer func() { _ = resp.Body.Close() }()
+ if resp.StatusCode == http.StatusConflict {
+ return nil // already exists
+ }
+ if resp.StatusCode/100 != 2 {
+ return fmt.Errorf("pdns create zone failed: status %d", resp.StatusCode)
+ }
+ return nil
+}
+
+func (c *Client) GetZone(ctx context.Context, zone string) (string, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/api/v1/servers/localhost/zones/"+zone+".", nil)
+ if err != nil {
+ return "", err
+ }
+ req.Header.Set("X-API-Key", c.APIKey)
+ resp, err := c.HTTP.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer func() { _ = resp.Body.Close() }()
+ if resp.StatusCode/100 != 2 {
+ return "", fmt.Errorf("pdns get zone failed: status %d", resp.StatusCode)
+ }
+ var zoneResponse struct {
+ Name string `json:"name"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&zoneResponse); err != nil {
+ return "", err
+ }
+ return zoneResponse.Name, nil
+}
+
+// in package pdns (same file as CreateZone/GetZone)
+func (c *Client) DeleteZone(ctx context.Context, zone string) error {
+ req, err := http.NewRequestWithContext(ctx, http.MethodDelete,
+ c.BaseURL+"/api/v1/servers/localhost/zones/"+zone+".", nil)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("X-API-Key", c.APIKey)
+ resp, err := c.HTTP.Do(req)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ // drain is optional for DELETE (usually no body), but Close error must be handled
+ _ = resp.Body.Close()
+ }()
+ if resp.StatusCode == http.StatusNotFound {
+ return nil // already gone
+ }
+ if resp.StatusCode/100 != 2 {
+ return fmt.Errorf("pdns delete zone failed: status %d", resp.StatusCode)
+ }
+ return nil
+}
+
+// GetZoneRRSets fetches all rrsets for a zone and returns them.
+func (c *Client) GetZoneRRSets(ctx context.Context, zone string) ([]zoneRRset, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/api/v1/servers/localhost/zones/"+zone+".", nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("X-API-Key", c.APIKey)
+ resp, err := c.HTTP.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = resp.Body.Close() }()
+ if resp.StatusCode == http.StatusNotFound {
+ // Zone not found yet; treat as empty rrsets for callers that already guard for zone readiness
+ return []zoneRRset{}, nil
+ }
+ if resp.StatusCode/100 != 2 {
+ return nil, fmt.Errorf("pdns get zone rrsets failed: status %d", resp.StatusCode)
+ }
+ var zr zoneResponse
+ if err := json.NewDecoder(resp.Body).Decode(&zr); err != nil {
+ return nil, err
+ }
+ return zr.RRSets, nil
+}
+
+type rrsetRecord struct {
+ Content string `json:"content"`
+ Disabled bool `json:"disabled"`
+}
+
+type rrset struct {
+ Name string `json:"name"`
+ Type string `json:"type"`
+ TTL int `json:"ttl"`
+ ChangeType string `json:"changetype"`
+ Records []rrsetRecord `json:"records"`
+}
+
+type patchZoneRequest struct {
+ RRSets []rrset `json:"rrsets"`
+}
+
+// Structures for GET zone response parsing
+type zoneResponse struct {
+ Name string `json:"name"`
+ RRSets []zoneRRset `json:"rrsets"`
+}
+
+type zoneRRset struct {
+ Name string `json:"name"`
+ Type string `json:"type"`
+ TTL int `json:"ttl"`
+ Records []zoneRRsetRecord `json:"records"`
+}
+
+type zoneRRsetRecord struct {
+ Content string `json:"content"`
+ Disabled bool `json:"disabled"`
+}
+
+// ApplyRecordSetAuthoritative ensures rrsets for the given record type match exactly the owners provided
+// in rs.Spec.Records: it REPLACEs provided owners and DELETEs any extra owners of the same type in PDNS.
+func (c *Client) ApplyRecordSetAuthoritative(ctx context.Context, zone string, rs dnsv1alpha1.DNSRecordSet) error {
+ // Build desired rrsets for this zone+type
+ desiredAll := buildRRSets(zone, rs)
+
+ // Filter only the target type (defensive) and normalize empty-record rrsets:
+ // - If an rrset has 0 records, PDNS will reject a REPLACE. Convert it to a DELETE instead.
+ desired := make([]rrset, 0, len(desiredAll))
+ desiredOwners := make(map[string]struct{}, len(desiredAll))
+ for _, rr := range desiredAll {
+ if rr.Type != string(rs.Spec.RecordType) {
+ continue
+ }
+ if len(rr.Records) == 0 {
+ rr.ChangeType = "DELETE"
+ } else {
+ rr.ChangeType = "REPLACE"
+ }
+ desired = append(desired, rr)
+ desiredOwners[rr.Name] = struct{}{}
+ }
+
+ // Fetch existing rrsets and find owners of this type to delete if not present in desired
+ existing, err := c.GetZoneRRSets(ctx, zone)
+ if err != nil {
+ return err
+ }
+ deletes := make([]rrset, 0)
+ for _, ex := range existing {
+ if ex.Type != string(rs.Spec.RecordType) {
+ continue
+ }
+ name := ex.Name // already absolute from PDNS
+ if _, ok := desiredOwners[name]; !ok {
+ deletes = append(deletes, rrset{
+ Name: name,
+ Type: ex.Type,
+ TTL: 0,
+ ChangeType: "DELETE",
+ Records: []rrsetRecord{},
+ })
+ }
+ }
+
+ // Compose patch payload (deterministic order helps debugging/tests)
+ patch := append(desired, deletes...)
+ sort.Slice(patch, func(i, j int) bool {
+ if patch[i].Type != patch[j].Type {
+ return patch[i].Type < patch[j].Type
+ }
+ if patch[i].Name != patch[j].Name {
+ return patch[i].Name < patch[j].Name
+ }
+ // DELETEs last so REPLACEs win when both accidentally appear
+ if patch[i].ChangeType != patch[j].ChangeType {
+ return patch[i].ChangeType < patch[j].ChangeType
+ }
+ return false
+ })
+
+ payload := patchZoneRequest{RRSets: patch}
+ body, _ := json.Marshal(payload)
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPatch,
+ c.BaseURL+"/api/v1/servers/localhost/zones/"+zone+".", bytes.NewReader(body))
+ if err != nil {
+ return err
+ }
+ req.Header.Set("X-API-Key", c.APIKey)
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := c.HTTP.Do(req)
+ if err != nil {
+ return err
+ }
+ if resp.StatusCode/100 != 2 {
+ errBody := readRespBody(resp, 64<<10) // closes Body
+ // include status + body so tests/logs show the real PDNS error
+ return &pdnsAPIError{Status: resp.StatusCode, Body: errBody}
+ }
+ _ = resp.Body.Close()
+ return nil
+}
+
+func buildRRSets(zone string, rs dnsv1alpha1.DNSRecordSet) []rrset {
+ type ownerKey = string
+ setsByOwner := make(map[ownerKey]*rrset, len(rs.Spec.Records))
+
+ getOrInit := func(owner string, ttl int) *rrset {
+ if existing, ok := setsByOwner[owner]; ok {
+ return existing
+ }
+ r := &rrset{
+ Name: owner,
+ Type: string(rs.Spec.RecordType),
+ TTL: ttl,
+ ChangeType: "REPLACE",
+ Records: []rrsetRecord{},
+ }
+ setsByOwner[owner] = r
+ return r
+ }
+
+ for _, rec := range rs.Spec.Records {
+ ttl := 300
+ if rec.TTL != nil {
+ ttl = int(*rec.TTL)
+ }
+ name := qualifyOwner(rec.Name, zone)
+ r := getOrInit(name, ttl)
+
+ switch rs.Spec.RecordType {
+ case dnsv1alpha1.RRTypeA:
+ if rec.A == nil {
+ continue
+ }
+ v := strings.TrimSpace(rec.A.Content)
+ if v != "" {
+ r.Records = append(r.Records, rrsetRecord{Content: v, Disabled: false})
+ }
+
+ case dnsv1alpha1.RRTypeAAAA:
+ if rec.AAAA == nil {
+ continue
+ }
+ v := strings.TrimSpace(rec.AAAA.Content)
+ if v != "" {
+ r.Records = append(r.Records, rrsetRecord{Content: v, Disabled: false})
+ }
+
+ case dnsv1alpha1.RRTypeCNAME:
+ if rec.CNAME == nil {
+ continue
+ }
+ target := strings.TrimSpace(rec.CNAME.Content)
+ target = qualifyIfNeeded(target)
+ if target != "" {
+ // TODO: Technically this is a violation of the RFC, but we'll allow it for now.
+ r.Records = append(r.Records, rrsetRecord{Content: target, Disabled: false})
+ }
+
+ case dnsv1alpha1.RRTypeTXT:
+ if rec.TXT == nil {
+ continue
+ }
+ if s := strings.TrimSpace(rec.TXT.Content); s != "" {
+ r.Records = append(r.Records, rrsetRecord{
+ Content: quoteIfNeeded(s),
+ Disabled: false,
+ })
+ }
+
+ case dnsv1alpha1.RRTypeMX:
+ if rec.MX == nil {
+ continue
+ }
+ exch := strings.TrimSpace(rec.MX.Exchange)
+ if exch != "" {
+ line := fmt.Sprintf("%d %s", rec.MX.Preference, qualifyIfNeeded(exch))
+ r.Records = append(r.Records, rrsetRecord{Content: line, Disabled: false})
+ }
+
+ case dnsv1alpha1.RRTypeSRV:
+ if rec.SRV == nil {
+ continue
+ }
+ tgt := strings.TrimSpace(rec.SRV.Target)
+ if tgt != "" {
+ line := fmt.Sprintf(
+ "%d %d %d %s",
+ rec.SRV.Priority,
+ rec.SRV.Weight,
+ rec.SRV.Port,
+ qualifyIfNeeded(tgt),
+ )
+ r.Records = append(r.Records, rrsetRecord{Content: line, Disabled: false})
+ }
+
+ case dnsv1alpha1.RRTypeCAA:
+ if rec.CAA == nil {
+ continue
+ }
+ line := fmt.Sprintf(
+ "%d %s %s",
+ rec.CAA.Flag,
+ rec.CAA.Tag,
+ quoteIfNeeded(rec.CAA.Value),
+ )
+ r.Records = append(r.Records, rrsetRecord{Content: line, Disabled: false})
+
+ case dnsv1alpha1.RRTypeNS:
+ if rec.NS == nil {
+ continue
+ }
+ v := strings.TrimSpace(rec.NS.Content)
+ if v != "" {
+ r.Records = append(r.Records, rrsetRecord{
+ Content: qualifyIfNeeded(v),
+ Disabled: false,
+ })
+ }
+
+ case dnsv1alpha1.RRTypeSOA:
+ if rec.SOA == nil {
+ continue
+ }
+
+ mname := qualifyIfNeeded(strings.TrimSpace(rec.SOA.MName))
+ rname := qualifyIfNeeded(strings.TrimSpace(rec.SOA.RName))
+
+ serial := fmt.Sprintf("%s01", time.Now().Format("20060102"))
+ if rec.SOA.Serial != 0 {
+ serial = fmt.Sprintf("%d", rec.SOA.Serial)
+ }
+
+ refresh := uint32(10800)
+ retry := uint32(3600)
+ expire := uint32(604800)
+ minimum := uint32(3600)
+ if rec.SOA.Refresh != 0 {
+ refresh = rec.SOA.Refresh
+ }
+ if rec.SOA.Retry != 0 {
+ retry = rec.SOA.Retry
+ }
+ if rec.SOA.Expire != 0 {
+ expire = rec.SOA.Expire
+ }
+ if rec.SOA.TTL != 0 {
+ minimum = rec.SOA.TTL
+ }
+
+ line := fmt.Sprintf(
+ "%s %s %s %d %d %d %d",
+ mname, rname, serial, refresh, retry, expire, minimum,
+ )
+
+ // SOA should be single-valued for a given owner; last one wins.
+ r.Records = []rrsetRecord{{Content: line, Disabled: false}}
+
+ case dnsv1alpha1.RRTypePTR:
+ // Adjust this once you have a typed PTR field in RecordEntry.
+ // For example, if you add:
+ // PTR *PTRRecordSpec `json:"ptr,omitempty"`
+ // and PTRRecordSpec has Content string:
+ //
+ // if rec.PTR != nil {
+ // v := strings.TrimSpace(rec.PTR.Content)
+ // if v != "" {
+ // r.Records = append(r.Records, rrsetRecord{
+ // Content: qualifyIfNeeded(v),
+ // Disabled: false,
+ // })
+ // }
+ // }
+ continue
+
+ case dnsv1alpha1.RRTypeTLSA:
+ if rec.TLSA == nil {
+ continue
+ }
+ line := fmt.Sprintf(
+ "%d %d %d %s",
+ rec.TLSA.Usage,
+ rec.TLSA.Selector,
+ rec.TLSA.MatchingType,
+ rec.TLSA.CertData,
+ )
+ r.Records = append(r.Records, rrsetRecord{Content: line, Disabled: false})
+
+ case dnsv1alpha1.RRTypeHTTPS:
+ if rec.HTTPS == nil {
+ continue
+ }
+ line := encodeSvcbLine(rec.HTTPS.Priority, rec.HTTPS.Target, rec.HTTPS.Params)
+ r.Records = append(r.Records, rrsetRecord{Content: line, Disabled: false})
+
+ case dnsv1alpha1.RRTypeSVCB:
+ if rec.SVCB == nil {
+ continue
+ }
+ line := encodeSvcbLine(rec.SVCB.Priority, rec.SVCB.Target, rec.SVCB.Params)
+ r.Records = append(r.Records, rrsetRecord{Content: line, Disabled: false})
+ }
+ }
+
+ // Convert map to slice with stable order by owner name.
+ out := make([]rrset, 0, len(setsByOwner))
+ owners := make([]string, 0, len(setsByOwner))
+ for owner := range setsByOwner {
+ owners = append(owners, owner)
+ }
+ sort.Strings(owners)
+ for _, owner := range owners {
+ out = append(out, *setsByOwner[owner])
+ }
+ return out
+}
+
+var (
+ svcbFlagKeys = map[string]struct{}{"no-default-alpn": {}}
+ svcbUnquotedCSV = map[string]struct{}{"alpn": {}, "ipv4hint": {}, "ipv6hint": {}, "port": {}}
+ svcbQuotedKeys = map[string]struct{}{"esnikeys": {}, "ech": {}}
+)
+
+// rank keys in PDNS-style canonical order
+func svcbKeyRank(k string) int {
+ switch k {
+ case "alpn":
+ return 10
+ case "no-default-alpn":
+ return 20
+ case "port":
+ return 30
+ case "esnikeys", "ech":
+ return 40
+ case "ipv4hint":
+ return 50
+ case "ipv6hint":
+ return 60
+ default:
+ return 1000 // unknowns after known ones
+ }
+}
+
+func encodeSvcbParams(m map[string]string) string {
+ if len(m) == 0 {
+ return ""
+ }
+ keys := make([]string, 0, len(m))
+ for k := range m {
+ keys = append(keys, k)
+ }
+ sort.Slice(keys, func(i, j int) bool {
+ ri, rj := svcbKeyRank(keys[i]), svcbKeyRank(keys[j])
+ if ri != rj {
+ return ri < rj
+ }
+ // stable within same rank
+ return keys[i] < keys[j]
+ })
+
+ parts := make([]string, 0, len(keys))
+ for _, k := range keys {
+ v := strings.TrimSpace(m[k])
+ if _, isFlag := svcbFlagKeys[k]; isFlag {
+ parts = append(parts, k)
+ continue
+ }
+ if v == "" {
+ continue
+ }
+ if _, unq := svcbUnquotedCSV[k]; unq {
+ parts = append(parts, fmt.Sprintf("%s=%s", k, v))
+ continue
+ }
+ if _, q := svcbQuotedKeys[k]; q {
+ parts = append(parts, fmt.Sprintf("%s=%s", k, quoteIfNeeded(v)))
+ continue
+ }
+ parts = append(parts, fmt.Sprintf("%s=%s", k, quoteIfNeeded(v)))
+ }
+ return strings.Join(parts, " ")
+}
+
+func encodeSvcbLine(priority uint16, target string, params map[string]string) string {
+ // target: "." for service-form with no alias; otherwise hostname (no trailing dot)
+ t := strings.TrimSpace(target)
+ switch t {
+ case ".":
+ // service-form: literal "." must be preserved
+ // (do not strip)
+ case "":
+ // default to service-form with no alias
+ t = "."
+ default:
+ t = qualifyIfNeeded(t)
+ }
+
+ // alias form: priority 0 => MUST have a target and MUST NOT have params
+ if priority == 0 {
+ return fmt.Sprintf("%d %s", priority, t)
+ }
+
+ p := encodeSvcbParams(params)
+ if p != "" {
+ return fmt.Sprintf("%d %s %s", priority, t, p)
+ }
+ return fmt.Sprintf("%d %s", priority, t)
+}
+
+func makeSimpleRRSet(name, typ string, ttl int, values []string) rrset {
+ recs := make([]rrsetRecord, 0, len(values))
+ for _, v := range values {
+ recs = append(recs, rrsetRecord{Content: v, Disabled: false})
+ }
+ return rrset{
+ Name: name,
+ Type: typ,
+ TTL: ttl,
+ ChangeType: "REPLACE",
+ Records: recs,
+ }
+}
+
+func qualifyOwner(owner, zone string) string {
+ if owner == "@" || owner == "" {
+ return zone + "."
+ }
+ if owner[len(owner)-1] == '.' {
+ return owner
+ }
+ return owner + "." + zone + "."
+}
+
+func qualifyIfNeeded(target string) string {
+ if target == "" {
+ return target
+ }
+ if target[len(target)-1] == '.' {
+ return target
+ }
+ return target + "."
+}
+
+func quoteIfNeeded(s string) string {
+ if len(s) >= 2 && (s[0] == '"' && s[len(s)-1] == '"') {
+ return s
+ }
+ return fmt.Sprintf("\"%s\"", s)
+}
+
+func stripTrailingDot(s string) string {
+ if strings.HasSuffix(s, ".") {
+ return s[:len(s)-1]
+ }
+ return s
+}
+
+// NewFromEnv constructs a PowerDNS API client using environment variables.
+//
+// Required/optional env vars:
+// - PDNS_API_URL: base URL for the HTTP API (default: http://127.0.0.1:8081)
+// - PDNS_API_KEY: API key (required)
+func NewFromEnv() (*Client, error) {
+ url := getenvDefault("PDNS_API_URL", "http://127.0.0.1:8081")
+ apiKey := os.Getenv("PDNS_API_KEY")
+ if apiKey == "" {
+ if path := os.Getenv("PDNS_API_KEY_FILE"); path != "" {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, fmt.Errorf("read PDNS_API_KEY_FILE: %w", err)
+ }
+ apiKey = string(bytes.TrimSpace(data))
+ }
+ }
+ if apiKey == "" {
+ return nil, fmt.Errorf("PDNS_API_KEY or PDNS_API_KEY_FILE is required")
+ }
+ return NewClient(url, apiKey), nil
+}
+
+func getenvDefault(key, def string) string {
+ if v := os.Getenv(key); v != "" {
+ return v
+ }
+ return def
+}
diff --git a/internal/pdns/pdns_integration_test.go b/internal/pdns/pdns_integration_test.go
new file mode 100644
index 0000000..5fc148f
--- /dev/null
+++ b/internal/pdns/pdns_integration_test.go
@@ -0,0 +1,430 @@
+package pdns
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/docker/docker/api/types/container"
+ "github.com/docker/docker/api/types/mount"
+ tc "github.com/testcontainers/testcontainers-go"
+ "github.com/testcontainers/testcontainers-go/wait"
+
+ dnsv1alpha1 "go.miloapis.com/dns-operator/api/v1alpha1"
+)
+
+func writePDNSAuthWithSQLite(t *testing.T, dir, apiKey string) {
+ t.Helper()
+ // Minimal authoritative config with API + SQLite backend
+ conf := strings.Join([]string{
+ "api=yes",
+ "api-key=" + apiKey,
+ "webserver=yes",
+ "webserver-address=0.0.0.0",
+ "webserver-port=8081",
+ "loglevel=6",
+
+ "webserver-allow-from=0.0.0.0/0,::/0",
+
+ "launch=gsqlite3",
+ "gsqlite3-database=/var/lib/powerdns/pdns.sqlite3",
+ }, "\n") + "\n"
+
+ if err := os.WriteFile(filepath.Join(dir, "pdns.conf"), []byte(conf), 0o644); err != nil {
+ t.Fatalf("write pdns.conf: %v", err)
+ }
+}
+
+func startPDNS(t *testing.T, apiKey string) (baseURL string, terminate func()) {
+ t.Helper()
+
+ ctx := context.Background()
+ cfgDir := t.TempDir()
+ dataDir := t.TempDir()
+ writePDNSAuthWithSQLite(t, cfgDir, apiKey)
+
+ // Use an official-ish PDNS authoritative image that reads /etc/powerdns/pdns.conf.
+ // You can pin a specific version if you prefer, e.g. powerdns/pdns-auth-46:latest
+ req := tc.GenericContainerRequest{
+ ContainerRequest: tc.ContainerRequest{
+ Image: "powerdns/pdns-auth-49:latest",
+ ExposedPorts: []string{"8081/tcp"},
+ HostConfigModifier: func(hc *container.HostConfig) {
+ hc.Mounts = append(hc.Mounts,
+ mount.Mount{
+ Type: mount.TypeBind,
+ Source: cfgDir,
+ Target: "/etc/powerdns",
+ ReadOnly: true,
+ },
+ mount.Mount{
+ Type: mount.TypeBind,
+ Source: dataDir,
+ Target: "/data",
+ },
+ )
+ },
+ WaitingFor: wait.ForHTTP("/api/v1/servers/localhost").
+ WithPort("8081/tcp").
+ WithHeaders(map[string]string{"X-API-Key": apiKey}).
+ WithStartupTimeout(2 * time.Minute),
+ },
+ Started: true,
+ }
+ c, err := tc.GenericContainer(ctx, req)
+ if err != nil {
+ t.Fatalf("start container: %v", err)
+ }
+
+ host, err := c.Host(ctx)
+ if err != nil {
+ _ = c.Terminate(ctx)
+ t.Fatalf("host: %v", err)
+ }
+ mp, err := c.MappedPort(ctx, "8081/tcp")
+ if err != nil {
+ _ = c.Terminate(ctx)
+ t.Fatalf("mapped port: %v", err)
+ }
+
+ return fmt.Sprintf("http://%s:%s", host, mp.Port()), func() {
+ _ = c.Terminate(context.Background())
+ }
+}
+
+func TestPDNS_EndToEnd_AllTypes(t *testing.T) {
+ // No t.Parallel(): we’re booting a container.
+ const apiKey = "itest-key"
+ baseURL, stop := startPDNS(t, apiKey)
+ defer stop()
+
+ client := NewClient(baseURL, apiKey)
+
+ zone := "example.test"
+ // Create the zone with some NS via the API create call
+ if err := client.CreateZone(context.Background(), zone, []string{"ns1.example.net", "ns2.example.net"}); err != nil {
+ t.Fatalf("CreateZone: %v", err)
+ }
+ if got, err := client.GetZone(context.Background(), zone); err != nil || got != zone+"." {
+ t.Fatalf("GetZone: got=%q err=%v", got, err)
+ }
+
+ // Build and apply records for each type
+ var ttl int64 = 300
+
+ apply := func(rt dnsv1alpha1.RRType, recs ...dnsv1alpha1.RecordEntry) {
+ rs := dnsv1alpha1.DNSRecordSet{
+ Spec: dnsv1alpha1.DNSRecordSetSpec{
+ RecordType: rt,
+ Records: recs,
+ },
+ }
+ if err := client.ApplyRecordSetAuthoritative(context.Background(), zone, rs); err != nil {
+ t.Fatalf("ApplyRecordSetAuthoritative(%s): %v", rt, err)
+ }
+ }
+
+ // A
+ apply(dnsv1alpha1.RRTypeA,
+ dnsv1alpha1.RecordEntry{Name: "@", TTL: &ttl, A: &dnsv1alpha1.ARecordSpec{Content: "1.2.3.4"}},
+ dnsv1alpha1.RecordEntry{Name: "www", TTL: &ttl, A: &dnsv1alpha1.ARecordSpec{Content: "1.2.3.5"}},
+ )
+ // AAAA
+ apply(dnsv1alpha1.RRTypeAAAA,
+ dnsv1alpha1.RecordEntry{Name: "v6", TTL: &ttl, AAAA: &dnsv1alpha1.AAAARecordSpec{Content: "2001:db8::1"}},
+ )
+ // CNAME
+ apply(dnsv1alpha1.RRTypeCNAME,
+ dnsv1alpha1.RecordEntry{Name: "alias", TTL: &ttl, CNAME: &dnsv1alpha1.CNAMERecordSpec{Content: "www." + zone + "."}},
+ )
+ // TXT (quoted)
+ apply(dnsv1alpha1.RRTypeTXT,
+ dnsv1alpha1.RecordEntry{Name: "txt", TTL: &ttl, TXT: &dnsv1alpha1.TXTRecordSpec{Content: "hello world"}},
+ )
+ // MX
+ apply(dnsv1alpha1.RRTypeMX,
+ dnsv1alpha1.RecordEntry{Name: "@", TTL: &ttl, MX: &dnsv1alpha1.MXRecordSpec{Preference: 10, Exchange: "mail." + zone + "."}},
+ )
+ // SRV
+ apply(dnsv1alpha1.RRTypeSRV,
+ dnsv1alpha1.RecordEntry{Name: "_https._tcp", TTL: &ttl, SRV: &dnsv1alpha1.SRVRecordSpec{Priority: 1, Weight: 0, Port: 443, Target: "www." + zone + "."}},
+ )
+ // CAA
+ apply(dnsv1alpha1.RRTypeCAA,
+ dnsv1alpha1.RecordEntry{Name: "@", TTL: &ttl, CAA: &dnsv1alpha1.CAARecordSpec{Flag: 0, Tag: "issue", Value: "letsencrypt.org"}},
+ )
+ // NS
+ apply(dnsv1alpha1.RRTypeNS,
+ dnsv1alpha1.RecordEntry{Name: "@", TTL: &ttl, NS: &dnsv1alpha1.NSRecordSpec{Content: "ns1.example.net."}},
+ dnsv1alpha1.RecordEntry{Name: "@", TTL: &ttl, NS: &dnsv1alpha1.NSRecordSpec{Content: "ns2.example.net."}},
+ )
+ // SOA (normalize mname/rname; serial auto)
+ apply(dnsv1alpha1.RRTypeSOA,
+ dnsv1alpha1.RecordEntry{
+ Name: "@",
+ TTL: &ttl,
+ SOA: &dnsv1alpha1.SOARecordSpec{MName: "ns1.example.net.", RName: "hostmaster.example.net."},
+ },
+ )
+ // // PTR (we’ll add it under a label in the same zone; PDNS doesn’t enforce reverse-zone semantics)
+ // apply(dnsv1alpha1.RRTypePTR,
+ // dnsv1alpha1.RecordEntry{Name: "ptrhost", TTL: &ttl, Raw: []string{"target." + zone + "."}},
+ // )
+ // TLSA
+ apply(dnsv1alpha1.RRTypeTLSA,
+ dnsv1alpha1.RecordEntry{Name: "_443._tcp", TTL: &ttl, TLSA: &dnsv1alpha1.TLSARecordSpec{Usage: 3, Selector: 1, MatchingType: 1, CertData: "ABCD"}},
+ )
+ // HTTPS: alias-form (prio 0) + service-form (.)
+ apply(dnsv1alpha1.RRTypeHTTPS,
+ dnsv1alpha1.RecordEntry{
+ Name: "https-alias",
+ TTL: &ttl,
+ HTTPS: &dnsv1alpha1.HTTPSRecordSpec{
+ Priority: 0, Target: "www." + zone + ".", // alias form
+ },
+ },
+ dnsv1alpha1.RecordEntry{
+ Name: "https",
+ TTL: &ttl,
+ HTTPS: &dnsv1alpha1.HTTPSRecordSpec{
+ Priority: 1, Target: ".", Params: map[string]string{
+ "alpn": "h2,h3",
+ "ipv4hint": "1.2.3.4,5.6.7.8",
+ "no-default-alpn": "",
+ },
+ },
+ },
+ )
+ // SVCB mirrors HTTPS behavior
+ apply(dnsv1alpha1.RRTypeSVCB,
+ dnsv1alpha1.RecordEntry{
+ Name: "svcb",
+ TTL: &ttl,
+ SVCB: &dnsv1alpha1.HTTPSRecordSpec{
+ Priority: 1, Target: ".", Params: map[string]string{"alpn": "h2", "port": "8443"},
+ },
+ },
+ )
+
+ // Fetch rrsets back and build an index type->name->[]content
+ sets, err := client.GetZoneRRSets(context.Background(), zone)
+ if err != nil {
+ t.Fatalf("GetZoneRRSets: %v", err)
+ }
+ type key struct{ typ, name string }
+ index := make(map[key][]string)
+ for _, s := range sets {
+ k := key{s.Type, s.Name}
+ for _, r := range s.Records {
+ index[k] = append(index[k], r.Content)
+ }
+ // stable order for deterministic checks
+ sort.Strings(index[k])
+ }
+
+ // helper for asserts with normalization
+ get := func(typ, owner string) []string {
+ return index[key{typ, qualifyOwner(owner, zone)}]
+ }
+ stripq := func(s string) string {
+ if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
+ return s[1 : len(s)-1]
+ }
+ return s
+ }
+
+ // A
+ if got := get("A", "@"); len(got) != 1 || got[0] != "1.2.3.4" {
+ t.Fatalf("A @ got=%v", got)
+ }
+ if got := get("A", "www"); len(got) != 1 || got[0] != "1.2.3.5" {
+ t.Fatalf("A www got=%v", got)
+ }
+
+ // AAAA
+ if got := get("AAAA", "v6"); len(got) != 1 || got[0] != "2001:db8::1" {
+ t.Fatalf("AAAA v6 got=%v", got)
+ }
+
+ // CNAME
+ if got := get("CNAME", "alias"); len(got) != 1 || stripTrailingDot(got[0]) != "www."+zone {
+ t.Fatalf("CNAME alias got=%v", got)
+ }
+
+ // TXT (we compare without quotes)
+ if got := get("TXT", "txt"); len(got) != 1 || stripq(got[0]) != "hello world" {
+ t.Fatalf("TXT txt got=%v", got)
+ }
+
+ // MX
+ if got := get("MX", "@"); len(got) != 1 || got[0] != "10 mail."+zone+"." {
+ t.Fatalf("MX @ got=%v", got)
+ }
+
+ // SRV
+ if got := get("SRV", "_https._tcp"); len(got) != 1 || !strings.HasSuffix(got[0], " www."+zone+".") {
+ t.Fatalf("SRV _https._tcp got=%v", got)
+ }
+
+ // CAA (quoted value)
+ if got := get("CAA", "@"); len(got) != 1 || stripq(strings.TrimPrefix(got[0], "0 issue ")) != "letsencrypt.org" {
+ t.Fatalf("CAA @ got=%v", got)
+ }
+
+ // NS
+ if got := get("NS", "@"); len(got) != 2 {
+ t.Fatalf("NS @ got=%v", got)
+ } else {
+ sort.Strings(got)
+ if got[0] != "ns1.example.net." || got[1] != "ns2.example.net." {
+ t.Fatalf("NS @ got=%v", got)
+ }
+ }
+
+ // SOA (check mname/rname; serial shape often managed by PDNS)
+ if got := get("SOA", "@"); len(got) != 1 {
+ t.Fatalf("SOA @ missing")
+ } else {
+ fields := strings.Fields(got[0])
+ if len(fields) < 2 || fields[0] != "ns1.example.net." || fields[1] != "hostmaster.example.net." {
+ t.Fatalf("SOA content: %q", got[0])
+ }
+ }
+
+ // // PTR
+ // if got := get("PTR", "ptrhost"); len(got) != 1 || got[0] != "target."+zone+"." {
+ // t.Fatalf("PTR ptrhost got=%v", got)
+ // }
+
+ // TLSA
+ if got := get("TLSA", "_443._tcp"); len(got) != 1 || got[0] != "3 1 1 abcd" {
+ t.Fatalf("TLSA _443._tcp got=%v", got)
+ }
+
+ // HTTPS alias form
+ if got := get("HTTPS", "https-alias"); len(got) != 1 || !strings.HasPrefix(got[0], "0 ") || !strings.Contains(got[0], " www."+zone) {
+ t.Fatalf("HTTPS alias got=%v", got)
+ }
+ // HTTPS service form (dot target + params)
+ if got := get("HTTPS", "https"); len(got) != 1 || !strings.HasPrefix(got[0], "1 . ") || !strings.Contains(got[0], `alpn=h2,h3`) || !strings.Contains(got[0], "ipv4hint=1.2.3.4,5.6.7.8") {
+ t.Fatalf("HTTPS service got=%v", got)
+ }
+
+ // SVCB service form
+ if got := get("SVCB", "svcb"); len(got) != 1 || !strings.HasPrefix(got[0], "1 . ") || !strings.Contains(got[0], `alpn=h2`) || !strings.Contains(got[0], "port=8443") {
+ t.Fatalf("SVCB service got=%v", got)
+ }
+}
+
+func TestPDNS_ApplyRecordSetAuthoritative_CleansRemovedOwners(t *testing.T) {
+ // No t.Parallel(): container + real PDNS.
+ const apiKey = "itest-key"
+ baseURL, stop := startPDNS(t, apiKey)
+ defer stop()
+
+ client := NewClient(baseURL, apiKey)
+ ctx := context.Background()
+ zone := "cleanup.test"
+
+ if err := client.CreateZone(ctx, zone, []string{"ns1.example.net", "ns2.example.net"}); err != nil {
+ t.Fatalf("CreateZone: %v", err)
+ }
+
+ var ttl int64 = 300
+
+ // Helper to index rrsets by (type, owner).
+ buildIndex := func(t *testing.T) (map[[2]string][]string, func(typ, owner string) []string) {
+ t.Helper()
+ sets, err := client.GetZoneRRSets(ctx, zone)
+ if err != nil {
+ t.Fatalf("GetZoneRRSets: %v", err)
+ }
+ index := make(map[[2]string][]string)
+ for _, s := range sets {
+ k := [2]string{s.Type, s.Name}
+ for _, r := range s.Records {
+ index[k] = append(index[k], r.Content)
+ }
+ sort.Strings(index[k])
+ }
+ get := func(typ, owner string) []string {
+ return index[[2]string{typ, qualifyOwner(owner, zone)}]
+ }
+ return index, get
+ }
+
+ // Initial: three A owners.
+ initial := dnsv1alpha1.DNSRecordSet{
+ Spec: dnsv1alpha1.DNSRecordSetSpec{
+ RecordType: dnsv1alpha1.RRTypeA,
+ Records: []dnsv1alpha1.RecordEntry{
+ {Name: "@", TTL: &ttl, A: &dnsv1alpha1.ARecordSpec{Content: "1.1.1.1"}},
+ {Name: "www", TTL: &ttl, A: &dnsv1alpha1.ARecordSpec{Content: "1.1.1.2"}},
+ {Name: "api", TTL: &ttl, A: &dnsv1alpha1.ARecordSpec{Content: "1.1.1.3"}},
+ },
+ },
+ }
+ if err := client.ApplyRecordSetAuthoritative(ctx, zone, initial); err != nil {
+ t.Fatalf("ApplyRecordSetAuthoritative(initial): %v", err)
+ }
+
+ _, get := buildIndex(t)
+
+ // Sanity: all three are present.
+ if got := get("A", "@"); len(got) != 1 || got[0] != "1.1.1.1" {
+ t.Fatalf("before: A @ got=%v", got)
+ }
+ if got := get("A", "www"); len(got) != 1 || got[0] != "1.1.1.2" {
+ t.Fatalf("before: A www got=%v", got)
+ }
+ if got := get("A", "api"); len(got) != 1 || got[0] != "1.1.1.3" {
+ t.Fatalf("before: A api got=%v", got)
+ }
+
+ // Capture NS/SOA count before we mutate A records, to verify we don't touch other types.
+ indexBefore, _ := buildIndex(t)
+ nsBefore := len(indexBefore[[2]string{"NS", qualifyOwner("@", zone)}])
+ soaBefore := len(indexBefore[[2]string{"SOA", qualifyOwner("@", zone)}])
+
+ // Updated: drop "www", change @ and api.
+ updated := dnsv1alpha1.DNSRecordSet{
+ Spec: dnsv1alpha1.DNSRecordSetSpec{
+ RecordType: dnsv1alpha1.RRTypeA,
+ Records: []dnsv1alpha1.RecordEntry{
+ {Name: "@", TTL: &ttl, A: &dnsv1alpha1.ARecordSpec{Content: "2.2.2.2"}},
+ {Name: "api", TTL: &ttl, A: &dnsv1alpha1.ARecordSpec{Content: "2.2.2.3"}},
+ },
+ },
+ }
+ if err := client.ApplyRecordSetAuthoritative(ctx, zone, updated); err != nil {
+ t.Fatalf("ApplyRecordSetAuthoritative(updated): %v", err)
+ }
+
+ indexAfter, get := buildIndex(t)
+
+ // Expect: @ and api updated…
+ if got := get("A", "@"); len(got) != 1 || got[0] != "2.2.2.2" {
+ t.Fatalf("after: A @ got=%v", got)
+ }
+ if got := get("A", "api"); len(got) != 1 || got[0] != "2.2.2.3" {
+ t.Fatalf("after: A api got=%v", got)
+ }
+
+ // …and www removed entirely (no rrset of type A at that owner).
+ if got := get("A", "www"); len(got) != 0 {
+ t.Fatalf("after: expected A www to be deleted, got=%v", got)
+ }
+
+ // Verify we did not touch NS/SOA rrsets (ApplyRecordSetAuthoritative is per-type).
+ if got := len(indexAfter[[2]string{"NS", qualifyOwner("@", zone)}]); got != nsBefore {
+ t.Fatalf("NS rrset count changed: before=%d after=%d", nsBefore, got)
+ }
+ if got := len(indexAfter[[2]string{"SOA", qualifyOwner("@", zone)}]); got != soaBefore {
+ t.Fatalf("SOA rrset count changed: before=%d after=%d", soaBefore, got)
+ }
+}
diff --git a/internal/pdns/pdns_test.go b/internal/pdns/pdns_test.go
new file mode 100644
index 0000000..b08c838
--- /dev/null
+++ b/internal/pdns/pdns_test.go
@@ -0,0 +1,612 @@
+package pdns
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+
+ dnsv1alpha1 "go.miloapis.com/dns-operator/api/v1alpha1"
+)
+
+const (
+ ns1ExampleNet = "ns1.example.net."
+ exampleCom = "example.com."
+)
+
+func TestCreateGetDeleteZoneAndRRSets(t *testing.T) {
+ t.Parallel()
+
+ var lastReq struct {
+ Method string
+ URL string
+ Body []byte
+ Headers http.Header
+ }
+
+ // minimal fake PDNS
+ mux := http.NewServeMux()
+ mux.HandleFunc("/api/v1/servers/localhost/zones", func(w http.ResponseWriter, r *http.Request) {
+ lastReq.Method = r.Method
+ lastReq.URL = r.URL.String()
+ lastReq.Headers = r.Header.Clone()
+ body, _ := io.ReadAll(r.Body)
+ lastReq.Body = body
+
+ if r.Method == http.MethodPost {
+ // Accept creation
+ w.WriteHeader(http.StatusCreated)
+ return
+ }
+ w.WriteHeader(http.StatusMethodNotAllowed)
+ })
+ mux.HandleFunc("/api/v1/servers/localhost/zones/example.com.", func(w http.ResponseWriter, r *http.Request) {
+ lastReq.Method = r.Method
+ lastReq.URL = r.URL.String()
+ lastReq.Headers = r.Header.Clone()
+ body, _ := io.ReadAll(r.Body)
+ lastReq.Body = body
+
+ switch r.Method {
+ case http.MethodGet:
+ // Return a zone with two rrsets
+ resp := zoneResponse{
+ Name: exampleCom,
+ RRSets: []zoneRRset{
+ {Name: exampleCom, Type: "A", TTL: 300, Records: []zoneRRsetRecord{{Content: "1.2.3.4"}}},
+ {Name: "www.example.com.", Type: "CNAME", TTL: 300, Records: []zoneRRsetRecord{{Content: "target"}}},
+ },
+ }
+ _ = json.NewEncoder(w).Encode(resp)
+ case http.MethodDelete:
+ w.WriteHeader(http.StatusNoContent)
+ case http.MethodPatch:
+ // accept any patch; return 204
+ w.WriteHeader(http.StatusNoContent)
+ default:
+ w.WriteHeader(http.StatusMethodNotAllowed)
+ }
+ })
+
+ srv := httptest.NewServer(mux)
+ defer srv.Close()
+
+ c := NewClient(srv.URL, "sekret")
+
+ // CreateZone should wrap inputs correctly (trailing dots on zone/ns) and set headers
+ if err := c.CreateZone(context.Background(), "example.com", []string{"ns1.example.com", "ns2.example.com."}); err != nil {
+ t.Fatalf("CreateZone error: %v", err)
+ }
+ if lastReq.Method != "POST" || lastReq.URL != "/api/v1/servers/localhost/zones" {
+ t.Fatalf("CreateZone wrong request: %s %s", lastReq.Method, lastReq.URL)
+ }
+ if got := lastReq.Headers.Get("X-API-Key"); got != "sekret" {
+ t.Fatalf("missing/incorrect api key header: %q", got)
+ }
+ var cz createZoneRequest
+ if err := json.Unmarshal(lastReq.Body, &cz); err != nil {
+ t.Fatalf("CreateZone body decode: %v", err)
+ }
+ if cz.Name != exampleCom {
+ t.Fatalf("CreateZone name: got %q want %q", cz.Name, exampleCom)
+ }
+ if !reflect.DeepEqual(cz.Nameservers, []string{"ns1.example.com.", "ns2.example.com."}) {
+ t.Fatalf("CreateZone nameservers: got %#v", cz.Nameservers)
+ }
+
+ // GetZone
+ z, err := c.GetZone(context.Background(), "example.com")
+ if err != nil {
+ t.Fatalf("GetZone error: %v", err)
+ }
+ if z != exampleCom {
+ t.Fatalf("GetZone name: got %q", z)
+ }
+
+ // GetZoneRRSets
+ rrs, err := c.GetZoneRRSets(context.Background(), "example.com")
+ if err != nil {
+ t.Fatalf("GetZoneRRSets error: %v", err)
+ }
+ if len(rrs) != 2 {
+ t.Fatalf("GetZoneRRSets len: got %d", len(rrs))
+ }
+
+ // DeleteZone
+ if err := c.DeleteZone(context.Background(), "example.com"); err != nil {
+ t.Fatalf("DeleteZone error: %v", err)
+ }
+}
+
+func TestBuildRRSets_NormalizationAndFormats(t *testing.T) {
+ t.Parallel()
+
+ ttl := int64(120)
+
+ // A
+ rsA := dnsv1alpha1.DNSRecordSet{
+ Spec: dnsv1alpha1.DNSRecordSetSpec{
+ RecordType: dnsv1alpha1.RRTypeA,
+ Records: []dnsv1alpha1.RecordEntry{
+ {
+ Name: "www",
+ TTL: &ttl,
+ A: &dnsv1alpha1.ARecordSpec{Content: "1.2.3.4"},
+ },
+ },
+ },
+ }
+ rr := buildRRSets("example.com", rsA)
+ if len(rr) != 1 || rr[0].Type != "A" || rr[0].Name != "www.example.com." || rr[0].TTL != 120 || rr[0].Records[0].Content != "1.2.3.4" {
+ t.Fatalf("A rrset unexpected: %#v", rr)
+ }
+
+ // AAAA
+ rsAAAA := dnsv1alpha1.DNSRecordSet{
+ Spec: dnsv1alpha1.DNSRecordSetSpec{
+ RecordType: dnsv1alpha1.RRTypeAAAA,
+ Records: []dnsv1alpha1.RecordEntry{
+ {
+ Name: "www",
+ TTL: &ttl,
+ AAAA: &dnsv1alpha1.AAAARecordSpec{Content: "2001:db8::1"},
+ },
+ },
+ },
+ }
+ rr = buildRRSets("example.com", rsAAAA)
+ if rr[0].Type != "AAAA" || rr[0].Records[0].Content != "2001:db8::1" {
+ t.Fatalf("AAAA rrset unexpected: %#v", rr)
+ }
+
+ // CNAME
+ rsCNAME := dnsv1alpha1.DNSRecordSet{
+ Spec: dnsv1alpha1.DNSRecordSetSpec{
+ RecordType: dnsv1alpha1.RRTypeCNAME,
+ Records: []dnsv1alpha1.RecordEntry{
+ {
+ Name: "www",
+ TTL: &ttl,
+ CNAME: &dnsv1alpha1.CNAMERecordSpec{Content: "alias.example.net."},
+ },
+ },
+ },
+ }
+ rr = buildRRSets("example.com", rsCNAME)
+ if rr[0].Type != "CNAME" || rr[0].Records[0].Content != "alias.example.net." {
+ t.Fatalf("CNAME rrset unexpected: %#v", rr)
+ }
+
+ // TXT: quoted
+ rsTXT := dnsv1alpha1.DNSRecordSet{
+ Spec: dnsv1alpha1.DNSRecordSetSpec{
+ RecordType: dnsv1alpha1.RRTypeTXT,
+ Records: []dnsv1alpha1.RecordEntry{
+ {
+ Name: "www",
+ TTL: &ttl,
+ TXT: &dnsv1alpha1.TXTRecordSpec{Content: "hello"},
+ },
+ },
+ },
+ }
+ rr = buildRRSets("example.com", rsTXT)
+ got := rr[0].Records[0].Content
+ if got != `"hello"` {
+ t.Fatalf("TXT quoting unexpected: %q", got)
+ }
+
+ // MX: PDNS payload uses absolute exchange (trailing dot)
+ rsMX := dnsv1alpha1.DNSRecordSet{
+ Spec: dnsv1alpha1.DNSRecordSetSpec{
+ RecordType: dnsv1alpha1.RRTypeMX,
+ Records: []dnsv1alpha1.RecordEntry{
+ {
+ Name: "mail",
+ TTL: &ttl,
+ MX: &dnsv1alpha1.MXRecordSpec{Preference: 10, Exchange: "mail.example.com."},
+ },
+ },
+ },
+ }
+ rr = buildRRSets("example.com", rsMX)
+ if rr[0].Type != "MX" || rr[0].Records[0].Content != "10 mail.example.com." {
+ t.Fatalf("MX rrset unexpected: %#v", rr)
+ }
+
+ // SRV: PDNS payload uses an absolute target with trailing dot
+ rsSRV := dnsv1alpha1.DNSRecordSet{
+ Spec: dnsv1alpha1.DNSRecordSetSpec{
+ RecordType: dnsv1alpha1.RRTypeSRV,
+ Records: []dnsv1alpha1.RecordEntry{
+ {
+ Name: "srv",
+ TTL: &ttl,
+ SRV: &dnsv1alpha1.SRVRecordSpec{Priority: 0, Weight: 5, Port: 443, Target: "svc.example.com."},
+ },
+ },
+ },
+ }
+ rr = buildRRSets("example.com", rsSRV)
+ if rr[0].Type != "SRV" || rr[0].Records[0].Content != "0 5 443 svc.example.com." {
+ t.Fatalf("SRV rrset unexpected: %#v", rr)
+ }
+
+ // CAA: quoted value
+ rsCAA := dnsv1alpha1.DNSRecordSet{
+ Spec: dnsv1alpha1.DNSRecordSetSpec{
+ RecordType: dnsv1alpha1.RRTypeCAA,
+ Records: []dnsv1alpha1.RecordEntry{
+ {
+ Name: "www",
+ TTL: &ttl,
+ CAA: &dnsv1alpha1.CAARecordSpec{Flag: 0, Tag: "issue", Value: "letsencrypt.org"},
+ },
+ },
+ },
+ }
+ rr = buildRRSets("example.com", rsCAA)
+ if rr[0].Type != "CAA" || rr[0].Records[0].Content != `0 issue "letsencrypt.org"` {
+ t.Fatalf("CAA rrset unexpected: %#v", rr)
+ }
+
+ // NS: multiple entries for same owner should group into a single rrset with multiple records
+ rsNS := dnsv1alpha1.DNSRecordSet{
+ Spec: dnsv1alpha1.DNSRecordSetSpec{
+ RecordType: dnsv1alpha1.RRTypeNS,
+ Records: []dnsv1alpha1.RecordEntry{
+ {
+ Name: "@",
+ TTL: &ttl,
+ NS: &dnsv1alpha1.NSRecordSpec{Content: ns1ExampleNet},
+ },
+ {
+ Name: "@",
+ NS: &dnsv1alpha1.NSRecordSpec{Content: "ns2.example.net."},
+ },
+ },
+ },
+ }
+ rr = buildRRSets("example.com", rsNS)
+ if len(rr) != 1 {
+ t.Fatalf("NS rrset grouping unexpected len: %#v", rr)
+ }
+ nsGot := []string{rr[0].Records[0].Content, rr[0].Records[1].Content}
+ // PDNS payload uses absolute hostnames (with trailing dots)
+ if rr[0].Type != "NS" || nsGot[0] != ns1ExampleNet || nsGot[1] != "ns2.example.net." {
+ t.Fatalf("NS rrset unexpected: %#v", rr)
+ }
+
+ // SOA
+ rsSOA := dnsv1alpha1.DNSRecordSet{
+ Spec: dnsv1alpha1.DNSRecordSetSpec{
+ RecordType: dnsv1alpha1.RRTypeSOA,
+ Records: []dnsv1alpha1.RecordEntry{
+ {
+ Name: "@",
+ TTL: &ttl,
+ SOA: &dnsv1alpha1.SOARecordSpec{
+ MName: ns1ExampleNet,
+ RName: "hostmaster.example.net.",
+ TTL: 3600,
+ Serial: 0, // trigger auto
+ },
+ },
+ },
+ },
+ }
+ rr = buildRRSets("example.com", rsSOA)
+ if rr[0].Type != "SOA" {
+ t.Fatalf("SOA type unexpected: %#v", rr)
+ }
+ parts := strings.Fields(rr[0].Records[0].Content)
+
+ // PDNS payload uses absolute hostnames (with trailing dots) for mname/rname
+ if len(parts) != 7 || parts[0] != ns1ExampleNet || parts[1] != "hostmaster.example.net." {
+ t.Fatalf("SOA content unexpected: %q", rr[0].Records[0].Content)
+ }
+
+ // serial like yyyymmddNN (we use %s01)
+ if len(parts[2]) != 10 {
+ t.Fatalf("SOA serial shape unexpected: %q", parts[2])
+ }
+
+ // TLSA: straight join
+ rsTLSA := dnsv1alpha1.DNSRecordSet{
+ Spec: dnsv1alpha1.DNSRecordSetSpec{
+ RecordType: dnsv1alpha1.RRTypeTLSA,
+ Records: []dnsv1alpha1.RecordEntry{
+ {
+ Name: "tlsa",
+ TTL: &ttl,
+ TLSA: &dnsv1alpha1.TLSARecordSpec{Usage: 3, Selector: 1, MatchingType: 1, CertData: "ABCD"},
+ },
+ },
+ },
+ }
+ rr = buildRRSets("example.com", rsTLSA)
+ if rr[0].Records[0].Content != "3 1 1 ABCD" {
+ t.Fatalf("TLSA rrset unexpected: %#v", rr)
+ }
+
+ // HTTPS (SVCB) – alias form only; params ignored for priority 0
+ rsHTTPS := dnsv1alpha1.DNSRecordSet{
+ Spec: dnsv1alpha1.DNSRecordSetSpec{
+ RecordType: dnsv1alpha1.RRTypeHTTPS,
+ Records: []dnsv1alpha1.RecordEntry{
+ {
+ Name: "svc",
+ TTL: &ttl,
+ HTTPS: &dnsv1alpha1.HTTPSRecordSpec{
+ Priority: 0,
+ Target: "alias.example.net.",
+ Params: map[string]string{"no-default-alpn": ""}, // params should be ignored
+ },
+ },
+ },
+ },
+ }
+ rr = buildRRSets("example.com", rsHTTPS)
+ if rr[0].Type != "HTTPS" {
+ t.Fatalf("HTTPS type unexpected: %#v", rr)
+ }
+ if len(rr[0].Records) != 1 {
+ t.Fatalf("HTTPS rrset expected single record, got %#v", rr[0].Records)
+ }
+ if got := rr[0].Records[0].Content; got != "0 alias.example.net." {
+ t.Fatalf("HTTPS alias form unexpected: %q", got)
+ }
+
+ // SVCB mirrors HTTPS behavior for a simple alias record
+ rsSVCB := dnsv1alpha1.DNSRecordSet{
+ Spec: dnsv1alpha1.DNSRecordSetSpec{
+ RecordType: dnsv1alpha1.RRTypeSVCB,
+ Records: []dnsv1alpha1.RecordEntry{
+ {
+ Name: "svc",
+ TTL: &ttl,
+ SVCB: &dnsv1alpha1.HTTPSRecordSpec{
+ Priority: 0,
+ Target: "alias.example.net.",
+ Params: map[string]string{"no-default-alpn": ""},
+ },
+ },
+ },
+ },
+ }
+ rr = buildRRSets("example.com", rsSVCB)
+ if rr[0].Type != "SVCB" {
+ t.Fatalf("SVCB type unexpected: %#v", rr)
+ }
+ if got := rr[0].Records[0].Content; got != "0 alias.example.net." {
+ t.Fatalf("SVCB alias form unexpected: %q", got)
+ }
+}
+
+func TestEncodeSvcbParamsAndLine(t *testing.T) {
+ t.Parallel()
+
+ // flags only
+ if got := encodeSvcbParams(map[string]string{"no-default-alpn": ""}); got != "no-default-alpn" {
+ t.Fatalf("flag encoding: %q", got)
+ }
+ // quoted + csv
+ p := encodeSvcbParams(map[string]string{
+ "alpn": "h2,h3",
+ "ipv4hint": "1.2.3.4,5.6.7.8",
+ "esnikeys": "abc",
+ "port": "443",
+ "unknown": "v", // default quoted
+ })
+ // order is deterministic (sorted keys)
+ wantParts := []string{`alpn=h2,h3`, `esnikeys="abc"`, `ipv4hint=1.2.3.4,5.6.7.8`, `port=443`, `unknown="v"`}
+ for _, w := range wantParts {
+ if !strings.Contains(p, w) {
+ t.Fatalf("missing part %q in %q", w, p)
+ }
+ }
+
+ // alias form (priority 0): no params, target normalized
+ if got := encodeSvcbLine(0, "name.example.", map[string]string{"alpn": "h2"}); got != "0 name.example." {
+ t.Fatalf("alias form wrong: %q", got)
+ }
+
+ // service form with "." target and params
+ got := encodeSvcbLine(1, ".", map[string]string{"no-default-alpn": "", "alpn": "h2"})
+ if got != `1 . no-default-alpn alpn=h2` && got != `1 . alpn=h2 no-default-alpn` {
+ t.Fatalf("service form wrong: %q", got)
+ }
+}
+
+func TestApplyRecordSetAuthoritative_PatchIncludesDeletes(t *testing.T) {
+ t.Parallel()
+
+ // Zone has an existing A rrset for "old.example.com."
+ existing := zoneResponse{
+ Name: exampleCom,
+ RRSets: []zoneRRset{
+ {Name: "old.example.com.", Type: "A", TTL: 300, Records: []zoneRRsetRecord{{Content: "9.9.9.9"}}},
+ {Name: "keep.example.com.", Type: "TXT", TTL: 300, Records: []zoneRRsetRecord{{Content: `"ok"`}}},
+ },
+ }
+
+ var capturedPatch patchZoneRequest
+
+ mux := http.NewServeMux()
+ mux.HandleFunc("/api/v1/servers/localhost/zones/example.com.", func(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case http.MethodGet:
+ _ = json.NewEncoder(w).Encode(existing)
+ case http.MethodPatch:
+ body, _ := io.ReadAll(r.Body)
+ _ = json.Unmarshal(body, &capturedPatch)
+ w.WriteHeader(http.StatusNoContent)
+ default:
+ w.WriteHeader(http.StatusMethodNotAllowed)
+ }
+ })
+
+ s := httptest.NewServer(mux)
+ defer s.Close()
+
+ c := NewClient(s.URL, "k")
+ ttl := int64(60)
+ // desired: A rrset for "new.example.com." only
+ rs := dnsv1alpha1.DNSRecordSet{
+ Spec: dnsv1alpha1.DNSRecordSetSpec{
+ RecordType: dnsv1alpha1.RRTypeA,
+ Records: []dnsv1alpha1.RecordEntry{
+ {Name: "new", TTL: &ttl, A: &dnsv1alpha1.ARecordSpec{Content: "1.1.1.1"}},
+ },
+ },
+ }
+
+ if err := c.ApplyRecordSetAuthoritative(context.Background(), "example.com", rs); err != nil {
+ t.Fatalf("ApplyRecordSetAuthoritative error: %v", err)
+ }
+
+ // Patch must contain:
+ // - REPLACE for new.example.com. A
+ // - DELETE for old.example.com. A
+ // (TXT rrset must be untouched)
+ var hasReplaceNew, hasDeleteOld bool
+ for _, r := range capturedPatch.RRSets {
+ if r.Type == "A" && r.Name == "new.example.com." && r.ChangeType == "REPLACE" {
+ hasReplaceNew = true
+ if len(r.Records) != 1 || r.Records[0].Content != "1.1.1.1" {
+ t.Fatalf("replace content wrong: %#v", r)
+ }
+ }
+ if r.Type == "A" && r.Name == "old.example.com." && r.ChangeType == "DELETE" {
+ hasDeleteOld = true
+ }
+ }
+ if !hasReplaceNew || !hasDeleteOld {
+ t.Fatalf("patch missing expected operations: %#v", capturedPatch.RRSets)
+ }
+}
+
+func TestHelpers(t *testing.T) {
+ t.Parallel()
+
+ if got := quoteIfNeeded("x"); got != `"x"` {
+ t.Fatalf("quoteIfNeeded: %q", got)
+ }
+ if got := quoteIfNeeded(`"x"`); got != `"x"` {
+ t.Fatalf("quoteIfNeeded pass-through: %q", got)
+ }
+ if got := qualifyOwner("@", "example.com"); got != exampleCom {
+ t.Fatalf("qualifyOwner @: %q", got)
+ }
+ if got := qualifyOwner("www", "example.com"); got != "www.example.com." {
+ t.Fatalf("qualifyOwner rel: %q", got)
+ }
+ if got := qualifyOwner("abs.example.", "example.com"); got != "abs.example." {
+ t.Fatalf("qualifyOwner abs: %q", got)
+ }
+}
+
+func TestNewFromEnv(t *testing.T) {
+ t.Setenv("PDNS_API_URL", "")
+ t.Setenv("PDNS_API_KEY", "")
+ t.Setenv("PDNS_API_KEY_FILE", "")
+
+ // missing creds => error
+ if _, err := NewFromEnv(); err == nil {
+ t.Fatal("expected error when no API key provided")
+ }
+
+ t.Setenv("PDNS_API_URL", "http://pdns:8081")
+ t.Setenv("PDNS_API_KEY", "abc123")
+ cli, err := NewFromEnv()
+ if err != nil {
+ t.Fatalf("NewFromEnv error: %v", err)
+ }
+ if cli.BaseURL != "http://pdns:8081" || cli.APIKey != "abc123" {
+ t.Fatalf("NewFromEnv result bad: %#v", cli)
+ }
+}
+
+func TestSOASerialAutoChangesPerDay(t *testing.T) {
+ t.Parallel()
+
+ // Set a fixed time base by checking only the prefix (YYYYMMDD)
+ // Build an SOA with no explicit Serial -> auto serial "yyyymmdd01"
+ rs := dnsv1alpha1.DNSRecordSet{
+ Spec: dnsv1alpha1.DNSRecordSetSpec{
+ RecordType: dnsv1alpha1.RRTypeSOA,
+ Records: []dnsv1alpha1.RecordEntry{{
+ Name: "@",
+ SOA: &dnsv1alpha1.SOARecordSpec{
+ MName: ns1ExampleNet,
+ RName: "hostmaster.example.net.",
+ },
+ }},
+ },
+ }
+ rrs := buildRRSets("example.com", rs)
+ if len(rrs) != 1 || len(rrs[0].Records) != 1 {
+ t.Fatalf("unexpected SOA rrsets: %#v", rrs)
+ }
+ serial := strings.Fields(rrs[0].Records[0].Content)[2]
+ // serial like "yyyymmdd01"
+ if len(serial) != 10 {
+ t.Fatalf("unexpected serial: %q", serial)
+ }
+ today := time.Now().Format("20060102")
+ if !strings.HasPrefix(serial, today) {
+ t.Fatalf("serial not using today's date: %q (want prefix %s)", serial, today)
+ }
+}
+
+// optional: ensures ApplyRecordSetAuthoritative uses PATCH path properly
+func TestApplyRecordSetAuthoritative_PathAndHeaders(t *testing.T) {
+ t.Parallel()
+
+ // capture path and method for PATCH; respond to GET with empty rrsets
+ var gotMethod, gotPath, gotAPIKey string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case http.MethodGet:
+ _ = json.NewEncoder(w).Encode(zoneResponse{Name: exampleCom, RRSets: nil})
+ case http.MethodPatch:
+ gotMethod = r.Method
+ gotPath = r.URL.Path
+ gotAPIKey = r.Header.Get("X-API-Key")
+ w.WriteHeader(http.StatusNoContent)
+ default:
+ w.WriteHeader(http.StatusNoContent)
+ }
+ }))
+ defer ts.Close()
+
+ c := NewClient(ts.URL, "k3y")
+ rs := dnsv1alpha1.DNSRecordSet{
+ Spec: dnsv1alpha1.DNSRecordSetSpec{
+ RecordType: dnsv1alpha1.RRTypeA,
+ Records: []dnsv1alpha1.RecordEntry{
+ {Name: "@", A: &dnsv1alpha1.ARecordSpec{Content: "8.8.8.8"}},
+ },
+ },
+ }
+ if err := c.ApplyRecordSetAuthoritative(context.Background(), "example.com", rs); err != nil {
+ t.Fatalf("ApplyRecordSetAuthoritative error: %v", err)
+ }
+ if gotMethod != http.MethodPatch || gotPath != "/api/v1/servers/localhost/zones/example.com." || gotAPIKey != "k3y" {
+ t.Fatalf("unexpected patch request: method=%s path=%s key=%s", gotMethod, gotPath, gotAPIKey)
+ }
+}
+
+// sanity: makeSimpleRRSet keeps values verbatim (used after we normalize)
+func TestMakeSimpleRRSet(t *testing.T) {
+ t.Parallel()
+ rr := makeSimpleRRSet("x.example.", "TXT", 300, []string{`"a"`, `"b"`})
+ if rr.Name != "x.example." || rr.Type != "TXT" || rr.TTL != 300 || len(rr.Records) != 2 || rr.Records[0].Content != `"a"` {
+ t.Fatalf("unexpected rrset: %#v", rr)
+ }
+}
diff --git a/test/e2e/chainsaw-test.yaml b/test/e2e/chainsaw-test.yaml
new file mode 100644
index 0000000..5a1cd8d
--- /dev/null
+++ b/test/e2e/chainsaw-test.yaml
@@ -0,0 +1,416 @@
+# yaml-language-server: $schema=https://raw.githubusercontent.com/kyverno/chainsaw/main/.schemas/json/test-chainsaw-v1alpha1.json
+apiVersion: chainsaw.kyverno.io/v1alpha1
+kind: Test
+metadata:
+ name: dns-operator-end-to-end
+spec:
+ clusters:
+ upstream:
+ kubeconfig: kubeconfig-upstream
+ downstream:
+ kubeconfig: kubeconfig-downstream
+ cluster: upstream
+ steps:
+ - name: Prereq - create DNSZoneClass in upstream
+ try:
+ - create:
+ cluster: upstream
+ resource:
+ apiVersion: dns.networking.miloapis.com/v1alpha1
+ kind: DNSZoneClass
+ metadata:
+ name: powerdns-static
+ spec:
+ controllerName: powerdns
+ nameServerPolicy:
+ mode: Static
+ static:
+ servers:
+ - ns1.example.com
+ - ns2.example.com
+ defaults:
+ defaultTTL: 300
+ - assert:
+ cluster: upstream
+ resource:
+ apiVersion: dns.networking.miloapis.com/v1alpha1
+ kind: DNSZoneClass
+ metadata:
+ name: powerdns-static
+
+ - name: Prereq - create DNSZoneClass in downstream
+ try:
+ - create:
+ cluster: downstream
+ resource:
+ apiVersion: dns.networking.miloapis.com/v1alpha1
+ kind: DNSZoneClass
+ metadata:
+ name: powerdns-static
+ spec:
+ controllerName: powerdns
+ nameServerPolicy:
+ mode: Static
+ static:
+ servers:
+ - ns1.example.com
+ - ns2.example.com
+ defaults:
+ defaultTTL: 300
+ - assert:
+ cluster: downstream
+ resource:
+ apiVersion: dns.networking.miloapis.com/v1alpha1
+ kind: DNSZoneClass
+ metadata:
+ name: powerdns-static
+
+ - name: Create DNSZone upstream
+ try:
+ - create:
+ cluster: upstream
+ resource:
+ apiVersion: dns.networking.miloapis.com/v1alpha1
+ kind: DNSZone
+ metadata:
+ name: example-com
+ spec:
+ domainName: example.com
+ dnsZoneClassName: powerdns-static
+ - assert:
+ cluster: upstream
+ resource:
+ apiVersion: dns.networking.miloapis.com/v1alpha1
+ kind: DNSZone
+ metadata:
+ name: example-com
+
+ - name: Confirm DNSZone replicated downstream
+ try:
+ - script:
+ cluster: upstream
+ skipCommandOutput: true
+ skipLogOutput: true
+ content: |
+ kubectl get ns $NAMESPACE -o json
+ outputs:
+ - name: downstreamNamespaceName
+ value: (join('-', ['ns', json_parse($stdout).metadata.uid]))
+ - assert:
+ cluster: downstream
+ resource:
+ apiVersion: dns.networking.miloapis.com/v1alpha1
+ kind: DNSZone
+ metadata:
+ name: example-com
+ namespace: ($downstreamNamespaceName)
+
+ - name: Confirm DNSZone accounting downstream exists (configmap)
+ try:
+ - assert:
+ cluster: downstream
+ resource:
+ apiVersion: v1
+ kind: ConfigMap
+ metadata:
+ name: example.com
+ namespace: datum-downstream-dnszone-accounting
+ data:
+ owner: (join('/', ['single', ($namespace), 'example-com']))
+
+ - name: Sleep for 5 seconds
+ try:
+ - sleep:
+ duration: 5s
+
+ - name: Confirm DNSZone Accepted/Programmed upstream
+ try:
+ - assert:
+ cluster: upstream
+ resource:
+ apiVersion: dns.networking.miloapis.com/v1alpha1
+ kind: DNSZone
+ metadata:
+ name: example-com
+ status:
+ domainRef:
+ status:
+ nameservers:
+ - hostname: A.IANA-SERVERS.NET
+ ips:
+ - address: 199.43.135.53
+ registrantName: ICANN
+ - address: 2001:500:8f::53
+ registrantName: ICANN
+ - hostname: B.IANA-SERVERS.NET
+ ips:
+ - address: 199.43.133.53
+ registrantName: ICANN
+ - address: 2001:500:8d::53
+ registrantName: ICANN
+ conditions:
+ - type: Accepted
+ status: "True"
+ - type: Programmed
+ status: "True"
+
+ - name: Assert upstream DNSZone nameservers
+ try:
+ - assert:
+ cluster: upstream
+ resource:
+ apiVersion: dns.networking.miloapis.com/v1alpha1
+ kind: DNSZone
+ metadata:
+ name: example-com
+ status:
+ nameservers:
+ - ns1.example.com
+ - ns2.example.com
+
+ - name: Create DNSRecordSet upstream
+ try:
+ - create:
+ cluster: upstream
+ resource:
+ apiVersion: dns.networking.miloapis.com/v1alpha1
+ kind: DNSRecordSet
+ metadata:
+ name: www-example-com
+ spec:
+ dnsZoneRef:
+ name: example-com
+ recordType: A
+ records:
+ - name: www
+ ttl: 5
+ a:
+ content: 10.0.0.1
+ - assert:
+ cluster: upstream
+ resource:
+ apiVersion: dns.networking.miloapis.com/v1alpha1
+ kind: DNSRecordSet
+ metadata:
+ name: www-example-com
+ ownerReferences:
+ - apiVersion: dns.networking.miloapis.com/v1alpha1
+ kind: DNSZone
+ name: example-com
+
+ - name: Confirm DNSRecordSet replicated downstream
+ try:
+ - script:
+ cluster: upstream
+ skipCommandOutput: true
+ skipLogOutput: true
+ content: |
+ kubectl get ns $NAMESPACE -o json
+ outputs:
+ - name: downstreamNamespaceName
+ value: (join('-', ['ns', json_parse($stdout).metadata.uid]))
+ - assert:
+ cluster: downstream
+ resource:
+ apiVersion: dns.networking.miloapis.com/v1alpha1
+ kind: DNSRecordSet
+ metadata:
+ name: www-example-com
+ namespace: ($downstreamNamespaceName)
+
+ - name: Confirm DNSRecordSet Accepted/Programmed upstream
+ try:
+ - assert:
+ cluster: upstream
+ resource:
+ apiVersion: dns.networking.miloapis.com/v1alpha1
+ kind: DNSRecordSet
+ metadata:
+ name: www-example-com
+ status:
+ conditions:
+ - type: Accepted
+ status: "True"
+ - type: Programmed
+ status: "True"
+
+ - name: DNS queries from downstream to PDNS (SOA/NS/A)
+ try:
+ - create:
+ cluster: downstream
+ resource:
+ apiVersion: v1
+ kind: Pod
+ metadata:
+ name: dnsutils
+ namespace: default
+ spec:
+ restartPolicy: Never
+ containers:
+ - name: dns
+ image: infoblox/dnstools:latest
+ command: ["sleep","3600"]
+ - assert:
+ cluster: downstream
+ resource:
+ apiVersion: v1
+ kind: Pod
+ metadata:
+ name: dnsutils
+ namespace: default
+ status:
+ phase: Running
+ - sleep:
+ duration: 3s
+ - script:
+ cluster: downstream
+ content: |
+ set -euo pipefail
+ # wait for pod ready condition
+ kubectl wait --for=condition=Ready --timeout=120s -n default pod/dnsutils
+ # Execute dig from inside the dnsutils pod (in-cluster DNS context)
+ kubectl -n default exec pod/dnsutils -- sh -c "dig @pdns-auth.dns-agent-system.svc.cluster.local example.com SOA +short | awk '{print \$1}' | grep -E '^ns1\\.example\\.com\.$'"
+ kubectl -n default exec pod/dnsutils -- sh -c "dig @pdns-auth.dns-agent-system.svc.cluster.local example.com NS +short | grep -E '^ns1\\.example\\.com\.$'"
+ kubectl -n default exec pod/dnsutils -- sh -c "dig @pdns-auth.dns-agent-system.svc.cluster.local example.com NS +short | grep -E '^ns2\\.example\\.com\.$'"
+ kubectl -n default exec pod/dnsutils -- sh -c "dig @pdns-auth.dns-agent-system.svc.cluster.local www.example.com A +short | grep -E '^10\\.0\\.0\\.1$'"
+
+ - name: Assert DNSZone recordCount is 4 (SOA + NS (2) + A)
+ try:
+ - assert:
+ cluster: upstream
+ resource:
+ apiVersion: dns.networking.miloapis.com/v1alpha1
+ kind: DNSZone
+ metadata:
+ name: example-com
+ status:
+ recordCount: 4
+
+ - name: Delete A record and verify PDNS cleanup
+ try:
+ - delete:
+ cluster: upstream
+ ref:
+ apiVersion: dns.networking.miloapis.com/v1alpha1
+ kind: DNSRecordSet
+ name: www-example-com
+ - script:
+ cluster: upstream
+ content: |
+ set -euo pipefail
+ # Confirm it no longer exists upstream
+ if kubectl -n "$NAMESPACE" get dnsrecordset www-example-com >/dev/null 2>&1; then
+ echo "upstream DNSRecordSet still exists" >&2
+ exit 1
+ fi
+ - sleep:
+ duration: 10s
+ - script:
+ cluster: downstream
+ content: |
+ set -euo pipefail
+ # verify A record no longer resolves
+ kubectl -n default exec pod/dnsutils -- sh -lc '
+ out=$(dig @pdns-auth.dns-agent-system.svc.cluster.local www.example.com A +short +tries=1 +time=1);
+ printf ">>>%s<<<\n" "$out";
+ [ -z "$out" ] && echo "EMPTY" || echo "NONEMPTY"
+ '
+ - assert:
+ cluster: upstream
+ resource:
+ apiVersion: dns.networking.miloapis.com/v1alpha1
+ kind: DNSZone
+ metadata:
+ name: example-com
+ status:
+ recordCount: 3
+
+ - name: Delete DNSZone and confirm accounting configmap is deleted
+ try:
+ - delete:
+ cluster: upstream
+ ref:
+ apiVersion: dns.networking.miloapis.com/v1alpha1
+ kind: DNSZone
+ name: example-com
+
+ - error:
+ cluster: downstream
+ resource:
+ apiVersion: v1
+ kind: ConfigMap
+ metadata:
+ name: example.com
+ namespace: datum-downstream-dnszone-accounting
+
+ - name: Teardown — delete zone first, classes last
+ try:
+ # 1) Delete the upstream zone (idempotent)
+ - delete:
+ cluster: upstream
+ ref:
+ apiVersion: dns.networking.miloapis.com/v1alpha1
+ kind: DNSZone
+ name: example-com
+ expect:
+ - match:
+ apiVersion: dns.networking.miloapis.com/v1alpha1
+ kind: DNSZone
+ check:
+ # ok if delete succeeded OR it was already gone
+ ($error == null): true
+
+ # 2) Compute mapped downstream namespace (same trick you used earlier)
+ - script:
+ cluster: upstream
+ skipCommandOutput: true
+ skipLogOutput: true
+ content: |
+ kubectl get ns $NAMESPACE -o json
+ outputs:
+ - name: downstreamNamespaceName
+ value: (join('-', ['ns', json_parse($stdout).metadata.uid]))
+
+ # 3) Prove downstream zone is gone by attempting a delete and expecting an error
+ - delete:
+ cluster: downstream
+ ref:
+ apiVersion: dns.networking.miloapis.com/v1alpha1
+ kind: DNSZone
+ namespace: ($downstreamNamespaceName)
+ name: example-com
+ expect:
+ - match:
+ apiVersion: dns.networking.miloapis.com/v1alpha1
+ kind: DNSZone
+ check:
+ # should be NotFound now
+ ($error != null): true
+ - sleep:
+ duration: 5s
+
+ # 5) Now it’s safe to delete the DNSZoneClass (downstream first, then upstream)
+ - delete:
+ cluster: downstream
+ ref:
+ apiVersion: dns.networking.miloapis.com/v1alpha1
+ kind: DNSZoneClass
+ name: powerdns-static
+ expect:
+ - match:
+ apiVersion: dns.networking.miloapis.com/v1alpha1
+ kind: DNSZoneClass
+ check:
+ ($error == null): true
+ - delete:
+ cluster: upstream
+ ref:
+ apiVersion: dns.networking.miloapis.com/v1alpha1
+ kind: DNSZoneClass
+ name: powerdns-static
+ expect:
+ - match:
+ apiVersion: dns.networking.miloapis.com/v1alpha1
+ kind: DNSZoneClass
+ check:
+ ($error == null): true
diff --git a/test/e2e/e2e_suite_test.go b/test/e2e/e2e_suite_test.go
deleted file mode 100644
index 6a51d3c..0000000
--- a/test/e2e/e2e_suite_test.go
+++ /dev/null
@@ -1,92 +0,0 @@
-//go:build e2e
-// +build e2e
-
-/*
-Copyright 2025.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-package e2e
-
-import (
- "fmt"
- "os"
- "os/exec"
- "testing"
-
- . "github.com/onsi/ginkgo/v2"
- . "github.com/onsi/gomega"
-
- "go.miloapis.com/dns-operator/test/utils"
-)
-
-var (
- // Optional Environment Variables:
- // - CERT_MANAGER_INSTALL_SKIP=true: Skips CertManager installation during test setup.
- // These variables are useful if CertManager is already installed, avoiding
- // re-installation and conflicts.
- skipCertManagerInstall = os.Getenv("CERT_MANAGER_INSTALL_SKIP") == "true"
- // isCertManagerAlreadyInstalled will be set true when CertManager CRDs be found on the cluster
- isCertManagerAlreadyInstalled = false
-
- // projectImage is the name of the image which will be build and loaded
- // with the code source changes to be tested.
- projectImage = "example.com/dns-operator:v0.0.1"
-)
-
-// TestE2E runs the end-to-end (e2e) test suite for the project. These tests execute in an isolated,
-// temporary environment to validate project changes with the purpose of being used in CI jobs.
-// The default setup requires Kind, builds/loads the Manager Docker image locally, and installs
-// CertManager.
-func TestE2E(t *testing.T) {
- RegisterFailHandler(Fail)
- _, _ = fmt.Fprintf(GinkgoWriter, "Starting dns-operator integration test suite\n")
- RunSpecs(t, "e2e suite")
-}
-
-var _ = BeforeSuite(func() {
- By("building the manager(Operator) image")
- cmd := exec.Command("make", "docker-build", fmt.Sprintf("IMG=%s", projectImage))
- _, err := utils.Run(cmd)
- ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to build the manager(Operator) image")
-
- // TODO(user): If you want to change the e2e test vendor from Kind, ensure the image is
- // built and available before running the tests. Also, remove the following block.
- By("loading the manager(Operator) image on Kind")
- err = utils.LoadImageToKindClusterWithName(projectImage)
- ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to load the manager(Operator) image into Kind")
-
- // The tests-e2e are intended to run on a temporary cluster that is created and destroyed for testing.
- // To prevent errors when tests run in environments with CertManager already installed,
- // we check for its presence before execution.
- // Setup CertManager before the suite if not skipped and if not already installed
- if !skipCertManagerInstall {
- By("checking if cert manager is installed already")
- isCertManagerAlreadyInstalled = utils.IsCertManagerCRDsInstalled()
- if !isCertManagerAlreadyInstalled {
- _, _ = fmt.Fprintf(GinkgoWriter, "Installing CertManager...\n")
- Expect(utils.InstallCertManager()).To(Succeed(), "Failed to install CertManager")
- } else {
- _, _ = fmt.Fprintf(GinkgoWriter, "WARNING: CertManager is already installed. Skipping installation...\n")
- }
- }
-})
-
-var _ = AfterSuite(func() {
- // Teardown CertManager after the suite if not skipped and if it was not already installed
- if !skipCertManagerInstall && !isCertManagerAlreadyInstalled {
- _, _ = fmt.Fprintf(GinkgoWriter, "Uninstalling CertManager...\n")
- utils.UninstallCertManager()
- }
-})
diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go
deleted file mode 100644
index 290a113..0000000
--- a/test/e2e/e2e_test.go
+++ /dev/null
@@ -1,334 +0,0 @@
-//go:build e2e
-// +build e2e
-
-/*
-Copyright 2025.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-package e2e
-
-import (
- "encoding/json"
- "fmt"
- "os"
- "os/exec"
- "path/filepath"
- "time"
-
- . "github.com/onsi/ginkgo/v2"
- . "github.com/onsi/gomega"
-
- "go.miloapis.com/dns-operator/test/utils"
-)
-
-// namespace where the project is deployed in
-const namespace = "dns-operator-system"
-
-// serviceAccountName created for the project
-const serviceAccountName = "dns-operator-controller-manager"
-
-// metricsServiceName is the name of the metrics service of the project
-const metricsServiceName = "dns-operator-controller-manager-metrics-service"
-
-// metricsRoleBindingName is the name of the RBAC that will be created to allow get the metrics data
-const metricsRoleBindingName = "dns-operator-metrics-binding"
-
-var _ = Describe("Manager", Ordered, func() {
- var controllerPodName string
-
- // Before running the tests, set up the environment by creating the namespace,
- // enforce the restricted security policy to the namespace, installing CRDs,
- // and deploying the controller.
- BeforeAll(func() {
- By("creating manager namespace")
- cmd := exec.Command("kubectl", "create", "ns", namespace)
- _, err := utils.Run(cmd)
- Expect(err).NotTo(HaveOccurred(), "Failed to create namespace")
-
- By("labeling the namespace to enforce the restricted security policy")
- cmd = exec.Command("kubectl", "label", "--overwrite", "ns", namespace,
- "pod-security.kubernetes.io/enforce=restricted")
- _, err = utils.Run(cmd)
- Expect(err).NotTo(HaveOccurred(), "Failed to label namespace with restricted policy")
-
- By("installing CRDs")
- cmd = exec.Command("make", "install")
- _, err = utils.Run(cmd)
- Expect(err).NotTo(HaveOccurred(), "Failed to install CRDs")
-
- By("deploying the controller-manager")
- cmd = exec.Command("make", "deploy", fmt.Sprintf("IMG=%s", projectImage))
- _, err = utils.Run(cmd)
- Expect(err).NotTo(HaveOccurred(), "Failed to deploy the controller-manager")
- })
-
- // After all tests have been executed, clean up by undeploying the controller, uninstalling CRDs,
- // and deleting the namespace.
- AfterAll(func() {
- By("cleaning up the curl pod for metrics")
- cmd := exec.Command("kubectl", "delete", "pod", "curl-metrics", "-n", namespace)
- _, _ = utils.Run(cmd)
-
- By("undeploying the controller-manager")
- cmd = exec.Command("make", "undeploy")
- _, _ = utils.Run(cmd)
-
- By("uninstalling CRDs")
- cmd = exec.Command("make", "uninstall")
- _, _ = utils.Run(cmd)
-
- By("removing manager namespace")
- cmd = exec.Command("kubectl", "delete", "ns", namespace)
- _, _ = utils.Run(cmd)
- })
-
- // After each test, check for failures and collect logs, events,
- // and pod descriptions for debugging.
- AfterEach(func() {
- specReport := CurrentSpecReport()
- if specReport.Failed() {
- By("Fetching controller manager pod logs")
- cmd := exec.Command("kubectl", "logs", controllerPodName, "-n", namespace)
- controllerLogs, err := utils.Run(cmd)
- if err == nil {
- _, _ = fmt.Fprintf(GinkgoWriter, "Controller logs:\n %s", controllerLogs)
- } else {
- _, _ = fmt.Fprintf(GinkgoWriter, "Failed to get Controller logs: %s", err)
- }
-
- By("Fetching Kubernetes events")
- cmd = exec.Command("kubectl", "get", "events", "-n", namespace, "--sort-by=.lastTimestamp")
- eventsOutput, err := utils.Run(cmd)
- if err == nil {
- _, _ = fmt.Fprintf(GinkgoWriter, "Kubernetes events:\n%s", eventsOutput)
- } else {
- _, _ = fmt.Fprintf(GinkgoWriter, "Failed to get Kubernetes events: %s", err)
- }
-
- By("Fetching curl-metrics logs")
- cmd = exec.Command("kubectl", "logs", "curl-metrics", "-n", namespace)
- metricsOutput, err := utils.Run(cmd)
- if err == nil {
- _, _ = fmt.Fprintf(GinkgoWriter, "Metrics logs:\n %s", metricsOutput)
- } else {
- _, _ = fmt.Fprintf(GinkgoWriter, "Failed to get curl-metrics logs: %s", err)
- }
-
- By("Fetching controller manager pod description")
- cmd = exec.Command("kubectl", "describe", "pod", controllerPodName, "-n", namespace)
- podDescription, err := utils.Run(cmd)
- if err == nil {
- fmt.Println("Pod description:\n", podDescription)
- } else {
- fmt.Println("Failed to describe controller pod")
- }
- }
- })
-
- SetDefaultEventuallyTimeout(2 * time.Minute)
- SetDefaultEventuallyPollingInterval(time.Second)
-
- Context("Manager", func() {
- It("should run successfully", func() {
- By("validating that the controller-manager pod is running as expected")
- verifyControllerUp := func(g Gomega) {
- // Get the name of the controller-manager pod
- cmd := exec.Command("kubectl", "get",
- "pods", "-l", "control-plane=controller-manager",
- "-o", "go-template={{ range .items }}"+
- "{{ if not .metadata.deletionTimestamp }}"+
- "{{ .metadata.name }}"+
- "{{ \"\\n\" }}{{ end }}{{ end }}",
- "-n", namespace,
- )
-
- podOutput, err := utils.Run(cmd)
- g.Expect(err).NotTo(HaveOccurred(), "Failed to retrieve controller-manager pod information")
- podNames := utils.GetNonEmptyLines(podOutput)
- g.Expect(podNames).To(HaveLen(1), "expected 1 controller pod running")
- controllerPodName = podNames[0]
- g.Expect(controllerPodName).To(ContainSubstring("controller-manager"))
-
- // Validate the pod's status
- cmd = exec.Command("kubectl", "get",
- "pods", controllerPodName, "-o", "jsonpath={.status.phase}",
- "-n", namespace,
- )
- output, err := utils.Run(cmd)
- g.Expect(err).NotTo(HaveOccurred())
- g.Expect(output).To(Equal("Running"), "Incorrect controller-manager pod status")
- }
- Eventually(verifyControllerUp).Should(Succeed())
- })
-
- It("should ensure the metrics endpoint is serving metrics", func() {
- By("creating a ClusterRoleBinding for the service account to allow access to metrics")
- cmd := exec.Command("kubectl", "create", "clusterrolebinding", metricsRoleBindingName,
- "--clusterrole=dns-operator-metrics-reader",
- fmt.Sprintf("--serviceaccount=%s:%s", namespace, serviceAccountName),
- )
- _, err := utils.Run(cmd)
- Expect(err).NotTo(HaveOccurred(), "Failed to create ClusterRoleBinding")
-
- By("validating that the metrics service is available")
- cmd = exec.Command("kubectl", "get", "service", metricsServiceName, "-n", namespace)
- _, err = utils.Run(cmd)
- Expect(err).NotTo(HaveOccurred(), "Metrics service should exist")
-
- By("getting the service account token")
- token, err := serviceAccountToken()
- Expect(err).NotTo(HaveOccurred())
- Expect(token).NotTo(BeEmpty())
-
- By("waiting for the metrics endpoint to be ready")
- verifyMetricsEndpointReady := func(g Gomega) {
- cmd := exec.Command("kubectl", "get", "endpoints", metricsServiceName, "-n", namespace)
- output, err := utils.Run(cmd)
- g.Expect(err).NotTo(HaveOccurred())
- g.Expect(output).To(ContainSubstring("8443"), "Metrics endpoint is not ready")
- }
- Eventually(verifyMetricsEndpointReady).Should(Succeed())
-
- By("verifying that the controller manager is serving the metrics server")
- verifyMetricsServerStarted := func(g Gomega) {
- cmd := exec.Command("kubectl", "logs", controllerPodName, "-n", namespace)
- output, err := utils.Run(cmd)
- g.Expect(err).NotTo(HaveOccurred())
- g.Expect(output).To(ContainSubstring("controller-runtime.metrics\tServing metrics server"),
- "Metrics server not yet started")
- }
- Eventually(verifyMetricsServerStarted).Should(Succeed())
-
- By("creating the curl-metrics pod to access the metrics endpoint")
- cmd = exec.Command("kubectl", "run", "curl-metrics", "--restart=Never",
- "--namespace", namespace,
- "--image=curlimages/curl:latest",
- "--overrides",
- fmt.Sprintf(`{
- "spec": {
- "containers": [{
- "name": "curl",
- "image": "curlimages/curl:latest",
- "command": ["/bin/sh", "-c"],
- "args": ["curl -v -k -H 'Authorization: Bearer %s' https://%s.%s.svc.cluster.local:8443/metrics"],
- "securityContext": {
- "readOnlyRootFilesystem": true,
- "allowPrivilegeEscalation": false,
- "capabilities": {
- "drop": ["ALL"]
- },
- "runAsNonRoot": true,
- "runAsUser": 1000,
- "seccompProfile": {
- "type": "RuntimeDefault"
- }
- }
- }],
- "serviceAccountName": "%s"
- }
- }`, token, metricsServiceName, namespace, serviceAccountName))
- _, err = utils.Run(cmd)
- Expect(err).NotTo(HaveOccurred(), "Failed to create curl-metrics pod")
-
- By("waiting for the curl-metrics pod to complete.")
- verifyCurlUp := func(g Gomega) {
- cmd := exec.Command("kubectl", "get", "pods", "curl-metrics",
- "-o", "jsonpath={.status.phase}",
- "-n", namespace)
- output, err := utils.Run(cmd)
- g.Expect(err).NotTo(HaveOccurred())
- g.Expect(output).To(Equal("Succeeded"), "curl pod in wrong status")
- }
- Eventually(verifyCurlUp, 5*time.Minute).Should(Succeed())
-
- By("getting the metrics by checking curl-metrics logs")
- verifyMetricsAvailable := func(g Gomega) {
- metricsOutput, err := getMetricsOutput()
- g.Expect(err).NotTo(HaveOccurred(), "Failed to retrieve logs from curl pod")
- g.Expect(metricsOutput).NotTo(BeEmpty())
- g.Expect(metricsOutput).To(ContainSubstring("< HTTP/1.1 200 OK"))
- }
- Eventually(verifyMetricsAvailable, 2*time.Minute).Should(Succeed())
- })
-
- // +kubebuilder:scaffold:e2e-webhooks-checks
-
- // TODO: Customize the e2e test suite with scenarios specific to your project.
- // Consider applying sample/CR(s) and check their status and/or verifying
- // the reconciliation by using the metrics, i.e.:
- // metricsOutput, err := getMetricsOutput()
- // Expect(err).NotTo(HaveOccurred(), "Failed to retrieve logs from curl pod")
- // Expect(metricsOutput).To(ContainSubstring(
- // fmt.Sprintf(`controller_runtime_reconcile_total{controller="%s",result="success"} 1`,
- // strings.ToLower(),
- // ))
- })
-})
-
-// serviceAccountToken returns a token for the specified service account in the given namespace.
-// It uses the Kubernetes TokenRequest API to generate a token by directly sending a request
-// and parsing the resulting token from the API response.
-func serviceAccountToken() (string, error) {
- const tokenRequestRawString = `{
- "apiVersion": "authentication.k8s.io/v1",
- "kind": "TokenRequest"
- }`
-
- // Temporary file to store the token request
- secretName := fmt.Sprintf("%s-token-request", serviceAccountName)
- tokenRequestFile := filepath.Join("/tmp", secretName)
- err := os.WriteFile(tokenRequestFile, []byte(tokenRequestRawString), os.FileMode(0o644))
- if err != nil {
- return "", err
- }
-
- var out string
- verifyTokenCreation := func(g Gomega) {
- // Execute kubectl command to create the token
- cmd := exec.Command("kubectl", "create", "--raw", fmt.Sprintf(
- "/api/v1/namespaces/%s/serviceaccounts/%s/token",
- namespace,
- serviceAccountName,
- ), "-f", tokenRequestFile)
-
- output, err := cmd.CombinedOutput()
- g.Expect(err).NotTo(HaveOccurred())
-
- // Parse the JSON output to extract the token
- var token tokenRequest
- err = json.Unmarshal(output, &token)
- g.Expect(err).NotTo(HaveOccurred())
-
- out = token.Status.Token
- }
- Eventually(verifyTokenCreation).Should(Succeed())
-
- return out, err
-}
-
-// getMetricsOutput retrieves and returns the logs from the curl pod used to access the metrics endpoint.
-func getMetricsOutput() (string, error) {
- By("getting the curl-metrics logs")
- cmd := exec.Command("kubectl", "logs", "curl-metrics", "-n", namespace)
- return utils.Run(cmd)
-}
-
-// tokenRequest is a simplified representation of the Kubernetes TokenRequest API response,
-// containing only the token field that we need to extract.
-type tokenRequest struct {
- Status struct {
- Token string `json:"token"`
- } `json:"status"`
-}
diff --git a/test/utils/utils.go b/test/utils/utils.go
index cf67d90..e722032 100644
--- a/test/utils/utils.go
+++ b/test/utils/utils.go
@@ -1,18 +1,4 @@
-/*
-Copyright 2025.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
+// SPDX-License-Identifier: AGPL-3.0-only
package utils