diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..92453af --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +/.github +/target +/tests +/docs +/deploy +.vscode +.cursorrules +.idea +Dockerfile +.dockerignore +.gitignore +Makefile +README.md +AI_PLAN.md +LICENSE +release-please-config.json +.release-please-manifest.json diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000..90b8d40 --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,277 @@ +name: Release + +on: + push: + branches: + - main + +permissions: + contents: write + pull-requests: write + packages: write + id-token: write # required for cosign keyless signing + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + # OCI repository for the operator chart. Note: the GHCR namespace must + # already exist as a package; first push will create it. + CHART_OCI_REPO: oci://ghcr.io/${{ github.repository_owner }}/charts + +jobs: + # ============================================ + # Release Please - Multi-Package Version Management + # + # Two linked packages: + # - `.` (rust crate / operator binary) + # - `charts/sonarr-operator` (helm chart) + # Versions are kept in lockstep by the `linked-versions` plugin. + # ============================================ + release-please: + name: Release Please + runs-on: ubuntu-latest + outputs: + # Operator (root) package outputs + operator_released: ${{ steps.release.outputs['.--release_created'] }} + operator_tag: ${{ steps.release.outputs['.--tag_name'] }} + operator_version: ${{ steps.release.outputs['.--version'] }} + # Chart package outputs + chart_released: ${{ + steps.release.outputs['charts/sonarr-operator--release_created'] + }} + chart_tag: ${{ steps.release.outputs['charts/sonarr-operator--tag_name'] }} + chart_version: ${{ steps.release.outputs['charts/sonarr-operator--version'] }} + steps: + - name: DevOpsArrBOT token + id: DevOpsArrBOT + uses: getsentry/action-github-app-token@v3 + with: + app_id: '305652' + private_key: ${{ secrets.DEVOPSARRBOT_PRIVATE_KEY }} + + - name: Set DevOpsArrBOT config + run: | + git config --global user.name "devopsarr[bot]" + git config --global user.email "127950054+devopsarr[bot]@users.noreply.github.com" + + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ steps.DevOpsArrBOT.outputs.token }} + + - name: Release Please + id: release + uses: googleapis/release-please-action@v4 + with: + token: ${{ steps.DevOpsArrBOT.outputs.token }} + + # ============================================ + # Build, Push & Sign Multi-arch Operator Image + # ============================================ + build-and-push: + name: Build & Push Multi-arch Image + runs-on: ubuntu-latest + needs: release-please + if: ${{ needs.release-please.outputs.operator_released == 'true' }} + permissions: + contents: read + packages: write + id-token: write + steps: + - uses: actions/checkout@v4 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Install cosign + uses: sigstore/cosign-installer@v3 + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=semver,pattern=v{{version}},value=${{ needs.release-please.outputs.operator_version }} + type=semver,pattern=v{{major}}.{{minor}},value=${{ needs.release-please.outputs.operator_version }} + type=semver,pattern=v{{major}},value=${{ needs.release-please.outputs.operator_version }} + type=raw,value=latest + + - name: Build and push multi-arch Docker image + id: build + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Sign the image (keyless) + env: + COSIGN_EXPERIMENTAL: "true" + DIGEST: ${{ steps.build.outputs.digest }} + TAGS: ${{ steps.meta.outputs.tags }} + run: | + for tag in $TAGS; do + cosign sign --yes "${tag}@${DIGEST}" + done + + # ============================================ + # Publish & Sign Operator Helm Chart (OCI) + # ============================================ + publish-chart: + name: Publish Helm Chart + runs-on: ubuntu-latest + needs: [ release-please, build-and-push ] + # `always() && ...` lets the chart publish even if build-and-push was + # skipped (chart-only release), but blocks publish if image build failed. + if: ${{ always() && needs.release-please.outputs.chart_released == 'true' && + (needs.build-and-push.result == 'success' || + needs.build-and-push.result == 'skipped') }} + permissions: + contents: write + packages: write + id-token: write + steps: + - name: DevOpsArrBOT token + id: DevOpsArrBOT + uses: getsentry/action-github-app-token@v3 + with: + app_id: '305652' + private_key: ${{ secrets.DEVOPSARRBOT_PRIVATE_KEY }} + + - uses: actions/checkout@v4 + with: + # Pull the tagged release commit so Chart.yaml has the right version. + ref: ${{ needs.release-please.outputs.chart_tag }} + token: ${{ steps.DevOpsArrBOT.outputs.token }} + + - uses: azure/setup-helm@v4 + - uses: sigstore/cosign-installer@v3 + + - name: Log in to GHCR for Helm + run: | + echo "${{ secrets.GITHUB_TOKEN }}" | \ + helm registry login ${{ env.REGISTRY }} \ + -u "${{ github.actor }}" --password-stdin + + - name: Package chart + id: package + run: | + helm package charts/sonarr-operator -d dist/ + pkg=$(ls dist/sonarr-operator-*.tgz) + echo "path=${pkg}" >> "$GITHUB_OUTPUT" + echo "name=$(basename "${pkg}")" >> "$GITHUB_OUTPUT" + + - name: Push chart to GHCR + id: push + run: | + out=$(helm push "${{ steps.package.outputs.path }}" "${{ env.CHART_OCI_REPO }}" 2>&1) + echo "$out" + digest=$(echo "$out" | awk '/Digest:/ {print $2}') + echo "digest=${digest}" >> "$GITHUB_OUTPUT" + + - name: Sign chart (keyless) + env: + COSIGN_EXPERIMENTAL: "true" + run: | + cosign sign --yes \ + "${{ env.REGISTRY }}/${{ github.repository_owner }}/charts/sonarr-operator@${{ steps.push.outputs.digest }}" + + - name: Attach chart tarball to GH release + env: + GITHUB_TOKEN: ${{ steps.DevOpsArrBOT.outputs.token }} + run: | + gh release upload ${{ needs.release-please.outputs.chart_tag }} \ + "${{ steps.package.outputs.path }}" + + # ============================================ + # Regenerate Docs & CRDs and Attach to Release + # + # Runs on operator release: regenerates the helm-templated CRDs in the + # chart (source of truth), derives the plain CRDs + docs from them, + # commits chart + docs back, and attaches plain CRDs to the GH release + # for users who don't use Helm. + # ============================================ + publish-docs: + name: Publish Docs & CRDs + runs-on: ubuntu-latest + needs: release-please + if: ${{ needs.release-please.outputs.operator_released == 'true' }} + permissions: + contents: write + steps: + - name: DevOpsArrBOT token + id: DevOpsArrBOT + uses: getsentry/action-github-app-token@v3 + with: + app_id: '305652' + private_key: ${{ secrets.DEVOPSARRBOT_PRIVATE_KEY }} + + - uses: actions/checkout@v4 + with: + ref: main + token: ${{ steps.DevOpsArrBOT.outputs.token }} + + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + + - uses: actions/setup-go@v5 + with: + go-version: '1.22' + + - uses: azure/setup-helm@v4 + + - name: Generate helm-templated CRDs into the chart + run: | + rm -f charts/sonarr-operator/templates/crds/*.yaml + cargo run --bin crdgen -- --split charts/sonarr-operator/templates/crds --helm + + - name: Render plain CRDs (for docs and release asset) + run: | + mkdir -p artifacts/crds + helm template sonarr-operator charts/sonarr-operator \ + --namespace sonarr-operator-system \ + --set crds.install=true \ + --set crds.keep=false \ + --show-only 'templates/crds/*.yaml' \ + > artifacts/crds/all.yaml + + - name: Generate documentation + run: | + go install fybrik.io/crdoc@latest + mkdir -p docs/api + crdoc --resources artifacts/crds --output docs/api/crd-reference.md + + - name: Commit and push docs + run: | + git config user.name "devopsarr[bot]" + git config user.email "127950054+devopsarr[bot]@users.noreply.github.com" + git add charts/sonarr-operator/templates/crds/ docs/api/ + if git diff --cached --quiet; then + echo "No documentation changes to commit" + else + git commit -m "docs: update CRDs and documentation for ${{ needs.release-please.outputs.operator_tag }}" + git push + fi + + - name: Attach CRDs to release + env: + GITHUB_TOKEN: ${{ steps.DevOpsArrBOT.outputs.token }} + run: | + cp artifacts/crds/all.yaml crds.yaml + tar czf crds.tar.gz -C artifacts/crds . + gh release upload ${{ needs.release-please.outputs.operator_tag }} crds.tar.gz crds.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..117c7c2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,379 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + # ============================================ + # Lint and Format Check + # ============================================ + lint: + name: Lint & Format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + - name: Check formatting + run: cargo fmt -- --check + - name: Clippy + run: cargo clippy -- -D warnings + + # ============================================ + # Unit Tests + # ============================================ + test: + name: Unit Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Run unit tests + run: cargo test --lib + + # ============================================ + # Generate CRDs (shared artifact) + # + # The chart's templates/crds/ directory is the source of truth. We + # regenerate it here and also publish a plain (un-templated) copy for + # consumers that don't run helm (integration/e2e tests, docs). + # ============================================ + generate-crds: + name: Generate CRDs + runs-on: ubuntu-latest + needs: [ lint ] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - uses: azure/setup-helm@v4 + - name: Generate helm-templated CRDs into the chart + run: cargo run --bin crdgen -- --split charts/sonarr-operator/templates/crds + --helm + - name: Render plain CRDs for downstream consumers + run: | + mkdir -p artifacts/crds + # `helm template` with crds.install=true strips the Helm guard + # and produces vanilla CRDs that can be `kubectl apply`-ed. + helm template sonarr-operator charts/sonarr-operator \ + --namespace sonarr-operator-system \ + --set crds.install=true \ + --set crds.keep=false \ + --show-only 'templates/crds/*.yaml' \ + > artifacts/crds/all.yaml + - name: Upload CRDs artifact + uses: actions/upload-artifact@v4 + with: + name: crds + path: artifacts/crds/ + + # ============================================ + # Generate CRD Documentation + # ============================================ + docs: + name: Generate Docs + runs-on: ubuntu-latest + needs: [ generate-crds ] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.22' + - name: Download CRDs artifact + uses: actions/download-artifact@v4 + with: + name: crds + path: artifacts/crds/ + - name: Install crdoc + run: go install fybrik.io/crdoc@latest + - name: Generate CRD documentation + run: | + mkdir -p docs/api + crdoc --resources artifacts/crds --output docs/api/crd-reference.md + - name: Upload documentation artifact + uses: actions/upload-artifact@v4 + with: + name: docs + path: docs/api/ + + # ============================================ + # Helm Chart Lint & Template (Phase 2) + # ============================================ + chart-lint: + name: Helm Chart Lint + runs-on: ubuntu-latest + needs: [ lint ] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - uses: azure/setup-helm@v4 + - name: Regenerate helm-wrapped CRDs into the chart + run: cargo run --bin crdgen -- --split charts/sonarr-operator/templates/crds + --helm + - name: Fail on CRD drift + run: | + if ! git diff --quiet -- charts/sonarr-operator/templates/crds; then + echo "::error::Chart CRDs are out of sync with the Rust types." + echo "Run: cargo run --bin crdgen -- --split charts/sonarr-operator/templates/crds --helm" + git diff -- charts/sonarr-operator/templates/crds | head -200 + exit 1 + fi + - name: helm lint + run: helm lint charts/sonarr-operator + - name: helm template (defaults) + run: helm template sonarr-operator charts/sonarr-operator --namespace + sonarr-operator-system + - name: helm template (crds.install=false) + run: helm template sonarr-operator charts/sonarr-operator --namespace + sonarr-operator-system --set crds.install=false + + # ============================================ + # Build and Push Docker Image + # ============================================ + build-image: + name: Build & Push Image + runs-on: ubuntu-latest + needs: [ lint ] + permissions: + contents: read + packages: write + outputs: + image-tag: ${{ steps.meta.outputs.tags }} + short-sha: ${{ steps.short-sha.outputs.sha }} + steps: + - uses: actions/checkout@v4 + + - name: Get short SHA + id: short-sha + run: echo "sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT" + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=sha,prefix=,format=short + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + # ============================================ + # Integration Tests (CRD validation) + # ============================================ + integration-test: + name: Integration Tests (K8s ${{ matrix.k8s }}) + runs-on: ubuntu-latest + needs: [ generate-crds ] + strategy: + fail-fast: false + matrix: + k8s: [ v1.32, v1.33, latest ] + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + shared-key: integration-test + + - name: Download CRDs artifact + uses: actions/download-artifact@v4 + with: + name: crds + path: artifacts/crds/ + + - name: Set up k3d cluster + uses: nolar/setup-k3d-k3s@v1 + with: + version: ${{ matrix.k8s }} + k3d-name: sonarr-operator-test + github-token: ${{ secrets.GITHUB_TOKEN }} + k3d-args: '--no-lb --no-rollback --k3s-arg + --disable=traefik,servicelb,metrics-server@server:*' + + - name: Install CRDs + run: kubectl apply -f artifacts/crds/ + + - name: Wait for CRDs + run: | + for crd in sonarrs sonarrtags sonarrrootfolders sonarrindexers \ + sonarrdownloadclients sonarrnotifications \ + sonarrqualityprofiles sonarrseries sonarrautotags \ + sonarrcustomformats sonarrdelayprofiles \ + sonarrdownloadclientconfigs sonarrimportlists \ + sonarrindexerconfigs sonarrlanguageprofiles \ + sonarrmediamanagementconfigs sonarrmetadatas \ + sonarrnamingconfigs sonarrqualitydefinitions; do + kubectl wait --for=condition=established "crd/${crd}.devopsarr.io" --timeout=60s + done + + - name: Run integration tests + run: cargo test --test integration -- --ignored --test-threads=1 + + - name: Cleanup + if: always() + run: kubectl delete namespace sonarr-operator-test --ignore-not-found + + # ============================================ + # E2E Tests (Full reconciliation with Sonarr) + # ============================================ + e2e-test: + name: E2E Tests + runs-on: ubuntu-latest + timeout-minutes: 20 + needs: [ generate-crds, build-image ] + permissions: + contents: read + packages: read + env: + API_KEY: ${{ github.run_id }}${{ github.sha }}${{ github.run_attempt }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - uses: azure/setup-helm@v4 + + - name: Download CRDs artifact + uses: actions/download-artifact@v4 + with: + name: crds + path: artifacts/crds/ + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Pull operator image + run: docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ + needs.build-image.outputs.short-sha }} + + - name: Install k3d + run: curl -s https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | bash + + - name: Create k3d cluster with port mapping + run: | + k3d cluster create sonarr-e2e \ + --port 8989:30989@server:0 \ + --k3s-arg '--disable=traefik,servicelb,metrics-server@server:*' + + - name: Import operator image into k3d + run: | + k3d image import \ + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.build-image.outputs.short-sha }} \ + --cluster sonarr-e2e + + - name: Install operator via Helm chart + run: | + helm install sonarr-operator charts/sonarr-operator \ + --namespace sonarr-operator-system \ + --create-namespace \ + --set image.repository=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} \ + --set image.tag=${{ needs.build-image.outputs.short-sha }} \ + --wait --timeout=3m + for crd in sonarrs sonarrtags sonarrrootfolders sonarrindexers \ + sonarrdownloadclients sonarrnotifications \ + sonarrqualityprofiles sonarrseries sonarrautotags \ + sonarrcustomformats sonarrdelayprofiles \ + sonarrdownloadclientconfigs sonarrimportlists \ + sonarrindexerconfigs sonarrlanguageprofiles \ + sonarrmediamanagementconfigs sonarrmetadatas \ + sonarrnamingconfigs sonarrqualitydefinitions; do + kubectl wait --for=condition=established "crd/${crd}.devopsarr.io" --timeout=60s + done + echo "Operator is running" + + - name: Create API key secret + run: | + kubectl create secret generic sonarr-api-key \ + --from-literal=api-key="${API_KEY}" \ + -n default + + - name: Apply Sonarr CR + run: | + kubectl apply -f tests/e2e/fixtures/sonarr-instance.yaml + echo "Waiting for Sonarr CR to be ready..." + kubectl wait --for=jsonpath='{.status.conditions[?(@.type=="Ready")].status}'=True \ + sonarr/sonarr -n default --timeout=360s + echo "Sonarr instance is ready" + + - name: Wait for Sonarr API to be available + run: | + echo "Waiting for Sonarr API on localhost:8989..." + for i in $(seq 1 90); do + if curl -sf -H "X-Api-Key: ${API_KEY}" \ + http://localhost:8989/api/v3/system/status > /dev/null 2>&1; then + echo "Sonarr API is ready" + exit 0 + fi + echo "Attempt $i/90 - waiting..." + sleep 2 + done + echo "Sonarr API not reachable" + kubectl get pods -A + kubectl logs -l app.kubernetes.io/name=sonarr -n default --tail=50 || true + exit 1 + + - name: Run E2E tests + env: + SONARR_API_KEY: ${{ env.API_KEY }} + SONARR_URL: http://localhost:8989 + run: cargo test --test e2e -- --ignored --test-threads=1 --nocapture + + - name: Collect logs on failure + if: failure() + run: | + echo "=== Operator logs ===" + kubectl logs -n sonarr-operator-system -l app.kubernetes.io/name=sonarr-operator --tail=200 || echo "No operator logs" + echo "" + echo "=== Sonarr pod logs ===" + kubectl logs -l app.kubernetes.io/name=sonarr -n default --all-containers --tail=100 || echo "No Sonarr logs" + echo "" + echo "=== Sonarr CR status ===" + kubectl get sonarr -A -o yaml 2>/dev/null || true + echo "" + echo "=== All pods ===" + kubectl get pods -A -o wide + echo "" + echo "=== All devopsarr resources ===" + kubectl get sonarrs,sonarrtags,sonarrrootfolders,sonarrqualityprofiles -A 2>/dev/null || true + echo "" + echo "=== Events ===" + kubectl get events -A --sort-by='.lastTimestamp' | tail -40 diff --git a/.gitignore b/.gitignore index d01bd1a..508940f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,21 +1,60 @@ -# Generated by Cargo -# will have compiled files and executables -debug/ -target/ +# Rust build artifacts +/target/ +**/*.rs.bk -# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries -# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html -Cargo.lock +# Local build/render output +/dist/ +/artifacts/ -# These are backup files generated by rustfmt -**/*.rs.bk +# IDE and editor files +.idea/ +.vscode/ +*.swp +*.swo +*~ +.project +.classpath +.settings/ + +# OS files +.DS_Store +.DS_Store? +._* +Thumbs.db +ehthumbs.db +Desktop.ini + +# Environment and secrets +.env +.env.local +.env.*.local +*.pem +*.key +kubeconfig +kubeconfig.yaml + +# Local development +.cargo/ +*.log +*.tmp + +# Docker +.docker/ + +# Kubernetes testing artifacts +*.kubeconfig +kind-config.yaml + +# Coverage reports +*.profraw +*.profdata +coverage/ +lcov.info +tarpaulin-report.html -# MSVC Windows builds of rustc generate these, which store debugging information -*.pdb +# Documentation build +/doc/ -# RustRover -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ \ No newline at end of file +# Binary artifacts (if accidentally placed in root) +/sonarr-operator +/crdgen diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..af53bd7 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,4 @@ +{ + ".": "0.1.0", + "charts/sonarr-operator": "0.1.0" +} diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..70dd308 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2953 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", + "gloo-timers", + "tokio", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "cc" +version = "1.2.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", + "quote", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn", +] + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "educe" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "enum-ordinalize" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a1091a7bb1f8f2c4b28f1fe2cef4980ca2d410a3d727d67ecc3178c9b0800f0" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.13.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jiff" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67e8da4c49d6d9909fe03361f9b620f58898859f5c7aded68351e85e71ecf50" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c84ee7f197eca9a86c6fd6cb771e55eb991632f15f2bc3ca6ec838929e6e78" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f300e415e2134745ef75f04562dd0145405c2f7fd92065db029ac4b16b57fe90" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonpath-rust" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633a7320c4bb672863a3782e89b9094ad70285e097ff6832cddd0ec615beadfa" +dependencies = [ + "pest", + "pest_derive", + "regex", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "jsonptr" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5a3cc660ba5d72bce0b3bb295bf20847ccbb40fd423f3f05b61273672e561fe" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "k8s-openapi" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05a6d6f3611ad1d21732adbd7a2e921f598af6c92d71ae6e2620da4b67ee1f0d" +dependencies = [ + "base64", + "jiff", + "schemars 1.2.0", + "serde", + "serde_json", +] + +[[package]] +name = "kube" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dae7229247e4215781e5c5104a056e1e2163943e577f9084cf8bba7b5248f7a" +dependencies = [ + "k8s-openapi", + "kube-client", + "kube-core", + "kube-derive", + "kube-runtime", +] + +[[package]] +name = "kube-client" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010875e291a9c0a4e076f4f9c35b97d82fd2372cb3bc713252c3d08b7e73ce5b" +dependencies = [ + "base64", + "bytes", + "either", + "futures", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-timeout", + "hyper-util", + "jiff", + "jsonpath-rust", + "k8s-openapi", + "kube-core", + "pem", + "rustls", + "secrecy", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tracing", +] + +[[package]] +name = "kube-core" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac76281aa698dd34111e25b21f5f6561932a30feabab5357152be273f8a81bb" +dependencies = [ + "derive_more", + "form_urlencoded", + "http", + "jiff", + "json-patch", + "k8s-openapi", + "schemars 1.2.0", + "serde", + "serde-value", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "kube-derive" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "599c09721efcccc0e6a26e93df28c587da60ff5e099c657626fff2af0ae4cbb8" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn", +] + +[[package]] +name = "kube-runtime" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db43d26700f564baf850f681f3cb0f1195d2699bd379bfa70750ecec4dcb209" +dependencies = [ + "ahash", + "async-broadcast", + "async-stream", + "backon", + "educe", + "futures", + "hashbrown 0.16.1", + "hostname", + "json-patch", + "k8s-openapi", + "kube-client", + "parking_lot", + "pin-project", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe 0.1.6", + "openssl-sys", + "schannel", + "security-framework 2.11.1", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9eb05c21a464ea704b53158d358a31e6425db2f63a1a7312268b05fe2b75f7" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f9dbced329c441fa79d80472764b1a2c7e57123553b8519b36663a2fb234ed" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bb96d5051a78f44f43c8f712d8e810adb0ebf923fc9ed2655a7f66f63ba8ee5" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "602113b5b5e8621770cfd490cfd90b9f84ab29bd2b0e49ad83eb6d186cef2365" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "portable-atomic" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "mime_guess", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe 0.2.1", + "rustls-pki-types", + "schannel", + "security-framework 3.5.1", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54e910108742c57a770f492731f99be216a52fadd361b06c8fb59d74ccc267d2" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4908ad288c5035a8eb12cfdf0d49270def0a268ee162b75eeee0f85d155a7c45" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" +dependencies = [ + "base64", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.13.0", + "schemars 0.9.0", + "schemars 1.2.0", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c" +dependencies = [ + "darling 0.21.3", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.13.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "sonarr" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66a60e7bb8becc4b5ebd3761fdded2e7b9d756812a18d17a265844ca76d29a19" +dependencies = [ + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serde_with", + "url", +] + +[[package]] +name = "sonarr-operator" +version = "0.1.0" +dependencies = [ + "anyhow", + "assert-json-diff", + "base64", + "chrono", + "futures", + "k8s-openapi", + "kube", + "reqwest", + "rustls", + "schemars 1.2.0", + "serde", + "serde_json", + "serde_yaml", + "sonarr", + "thiserror 2.0.18", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.52.0", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9da98b7d9b7dad93488a84b8248efc35352b0b2657397d4167e7ad67e5d535e5" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78cc610bac2dcee56805c99642447d4c5dbde4d01f752ffea0199aee1f601dc4" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "slab", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "base64", + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "mime", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dafd85c832c1b68bbb4ec0c72c7f6f4fc5179627d2bc7c26b30e4c0cc11e76cc" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cb7e4e8436d9db52fbd6625dbf2f45243ab84994a72882ec8227b99e72b439a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02aae0f83f69aafc94776e879363e9771d7ecbffe2c7fbb6c14c5e00dfe88439" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..7b7b6fc --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,77 @@ +[package] +name = "sonarr-operator" +version = "0.1.0" +edition = "2024" +authors = ["DevOpsArr"] +description = "Kubernetes Operator for Sonarr written in Rust using kube-rs" +license = "GPL-3.0" +repository = "https://github.com/devopsarr/k8s-operator-sonarr" +keywords = ["kubernetes", "operator", "sonarr", "kube-rs"] + +[lib] +name = "sonarr_operator" +path = "src/lib.rs" + +[dependencies] +# Kubernetes client and runtime +kube = { version = "3.0.0", features = ["runtime", "derive", "client", "rustls-tls"], default-features = false } +k8s-openapi = { version = "0.27.0", features = ["latest", "schemars"] } + +# Crypto provider for rustls (required at runtime) +rustls = { version = "0.23", default-features = false, features = ["ring"] } + +# Async runtime +tokio = { version = "1", features = ["full"] } +futures = "0.3" + +# Serialization +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yaml = "0.9" + +# JSON Schema generation for CRDs +schemars = "1" + +# Sonarr API client +sonarr = { version = "0.1" } + +# Error handling +thiserror = "2" +anyhow = "1" + +# Logging and tracing +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } + +# Time handling +chrono = { version = "0.4", features = ["serde"] } + +# Base64 encoding for secrets +base64 = "0.22" + +[dev-dependencies] +assert-json-diff = "2" +reqwest = { version = "0.12", features = ["json"] } +anyhow = "1" + +[[bin]] +name = "sonarr-operator" +path = "src/main.rs" + +[[bin]] +name = "crdgen" +path = "src/bin/crdgen.rs" + +[[test]] +name = "integration" +path = "tests/integration/main.rs" + +[[test]] +name = "e2e" +path = "tests/e2e/main.rs" + +[profile.release] +lto = true +codegen-units = 1 +opt-level = "z" +strip = true diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d8d914d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +FROM rust:1.88-slim-bookworm AS builder + +WORKDIR /app + +# No system dependencies needed - project uses rustls + +# Copy manifests +COPY Cargo.toml Cargo.lock ./ + +# Create dummy src to cache dependencies +RUN mkdir src && \ + echo "fn main() {}" > src/main.rs && \ + echo "" > src/lib.rs && \ + mkdir -p src/bin && echo "fn main() {}" > src/bin/crdgen.rs + +# Build dependencies +RUN cargo build --release && rm -rf src + +# Copy actual source code +COPY src ./src + +# Build the application +RUN touch src/main.rs src/lib.rs && cargo build --release --bin sonarr-operator + +# Runtime image - use distroless for smaller image and no apt needed +FROM gcr.io/distroless/cc-debian12:nonroot + +COPY --from=builder /app/target/release/sonarr-operator /usr/local/bin/sonarr-operator + +ENTRYPOINT ["/usr/local/bin/sonarr-operator"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..619348d --- /dev/null +++ b/Makefile @@ -0,0 +1,214 @@ +# Sonarr Kubernetes Operator Makefile +# +# Usage: +# make help - Show this help +# make build - Build release binary +# make crds - Generate CRD manifests +# make test - Run unit tests +# make e2e-up - Full local E2E environment setup +# make e2e - Run E2E tests +# make e2e-down - Tear down local E2E environment + +# Configuration +BINARY_NAME := sonarr-operator +CHART_DIR := charts/sonarr-operator +CRD_DIR := $(CHART_DIR)/templates/crds +RENDERED_CRDS := dist/crds.yaml +NAMESPACE := sonarr-operator-system +K3D_CLUSTER := sonarr-e2e +E2E_API_KEY ?= test-e2e-api-key-12345 +SONARR_URL ?= http://localhost:8989 + +.PHONY: help +help: ## Show this help + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' + +##@ Development + +.PHONY: check +check: ## Run cargo check + cargo check + +.PHONY: fmt +fmt: ## Format code + cargo fmt + +.PHONY: fmt-check +fmt-check: ## Check code formatting + cargo fmt -- --check + +.PHONY: clippy +clippy: ## Run clippy linter + cargo clippy -- -D warnings + +.PHONY: lint +lint: fmt-check clippy ## Run all linters + +##@ Build + +.PHONY: build +build: ## Build release binary + cargo build --release + +.PHONY: build-debug +build-debug: ## Build debug binary + cargo build + +.PHONY: clean +clean: ## Clean build artifacts + cargo clean + +##@ Testing + +.PHONY: test +test: ## Run unit tests + cargo test --lib + +.PHONY: test-all +test-all: ## Run all non-ignored tests + cargo test + +.PHONY: integration-test +integration-test: install ## Run integration tests (requires cluster with CRDs) + cargo test --test integration -- --ignored --test-threads=1 + +.PHONY: integration-test-verbose +integration-test-verbose: install ## Run integration tests with verbose output + cargo test --test integration -- --ignored --test-threads=1 --nocapture + +##@ CRD Management + +.PHONY: crds +crds: ## Generate helm-templated CRDs into the chart + @mkdir -p $(CRD_DIR) + @rm -f $(CRD_DIR)/*.yaml + cargo run --bin crdgen -- --split $(CRD_DIR) --helm + +.PHONY: crds-render +crds-render: crds ## Render plain CRDs from the chart (for kubectl apply) + @mkdir -p $(dir $(RENDERED_CRDS)) + helm template sonarr-operator $(CHART_DIR) \ + --namespace $(NAMESPACE) \ + --set crds.install=true \ + --set crds.keep=false \ + --show-only 'templates/crds/*.yaml' \ + > $(RENDERED_CRDS) + +.PHONY: install +install: crds-render ## Install CRDs to cluster + kubectl apply -f $(RENDERED_CRDS) + +.PHONY: uninstall +uninstall: ## Remove CRDs from cluster + kubectl delete -f $(RENDERED_CRDS) --ignore-not-found + +##@ Documentation + +DOCS_DIR := docs/api +GOBIN := $(shell go env GOPATH)/bin + +.PHONY: docs +docs: crds-render ## Generate CRD documentation + @mkdir -p $(DOCS_DIR) + @if ! command -v crdoc >/dev/null 2>&1 && [ ! -f "$(GOBIN)/crdoc" ]; then \ + echo "Installing crdoc..."; \ + go install fybrik.io/crdoc@latest; \ + fi + @PATH="$(GOBIN):$$PATH" crdoc --resources $(RENDERED_CRDS) --output $(DOCS_DIR)/crd-reference.md + @echo "Documentation generated in $(DOCS_DIR)/crd-reference.md" + +##@ Running + +.PHONY: run +run: ## Run operator locally (requires kubeconfig) + RUST_LOG=info cargo run --bin sonarr-operator + +.PHONY: run-debug +run-debug: ## Run operator with debug logging + RUST_LOG=debug cargo run --bin sonarr-operator + +##@ Docker + +.PHONY: docker +docker: ## Build Docker image and import into k3d cluster + docker build -t $(BINARY_NAME):latest . + k3d image import $(BINARY_NAME):latest --cluster $(K3D_CLUSTER) + +##@ Kubernetes Deployment + +.PHONY: deploy +deploy: docker ## Deploy operator (chart) to cluster with local image + helm upgrade --install sonarr-operator $(CHART_DIR) \ + --namespace $(NAMESPACE) --create-namespace \ + --set image.repository=$(BINARY_NAME) \ + --set image.tag=latest \ + --set image.pullPolicy=Never \ + --wait + +.PHONY: undeploy +undeploy: ## Remove operator from cluster + helm uninstall sonarr-operator -n $(NAMESPACE) --ignore-not-found + +##@ Local E2E Testing (k3d) +# +# Full local E2E workflow: +# 1. make e2e-up - Create k3d cluster, install CRDs, deploy Sonarr +# 2. make run-debug - Run operator locally (in a separate terminal) +# 3. make e2e - Run E2E tests +# 4. make e2e-down - Tear down k3d cluster + +.PHONY: e2e-cluster-create +e2e-cluster-create: ## Create k3d cluster with port mapping (8989 -> 30989) + k3d cluster create $(K3D_CLUSTER) \ + --port 8989:30989@server:0 \ + --k3s-arg '--disable=traefik,servicelb,metrics-server@server:*' + +.PHONY: e2e-cluster-delete +e2e-cluster-delete: ## Delete k3d cluster + k3d cluster delete $(K3D_CLUSTER) + +.PHONY: e2e-deploy-sonarr +e2e-deploy-sonarr: install ## Create API key secret + apply Sonarr CR + kubectl create secret generic sonarr-api-key \ + --from-literal=api-key="$(E2E_API_KEY)" \ + -n default --dry-run=client -o yaml | kubectl apply -f - + kubectl apply -f tests/e2e/fixtures/sonarr-instance.yaml + @echo "Waiting for Sonarr to be ready..." + kubectl wait --for=jsonpath='{.status.conditions[?(@.type=="Ready")].status}'=True \ + sonarr/sonarr -n default --timeout=300s + @echo "Sonarr instance is ready" + +.PHONY: e2e-up +e2e-up: e2e-cluster-create e2e-deploy-sonarr ## Full E2E setup: create cluster + deploy Sonarr + @echo "" + @echo "E2E environment is ready." + @echo "Next steps:" + @echo " 1. Run the operator: make run-debug" + @echo " 2. Run E2E tests: make e2e" + +.PHONY: e2e-down +e2e-down: e2e-cluster-delete ## Tear down E2E environment + +.PHONY: e2e +e2e: ## Run E2E tests (requires operator + Sonarr running) + SONARR_API_KEY=$(E2E_API_KEY) SONARR_URL=$(SONARR_URL) \ + cargo test --test e2e -- --ignored --test-threads=1 + +.PHONY: e2e-verbose +e2e-verbose: ## Run E2E tests with verbose output + SONARR_API_KEY=$(E2E_API_KEY) SONARR_URL=$(SONARR_URL) RUST_LOG=debug \ + cargo test --test e2e -- --ignored --test-threads=1 --nocapture + +.PHONY: e2e-cleanup +e2e-cleanup: ## Cleanup E2E test resources (without deleting cluster) + kubectl delete -f tests/e2e/fixtures/sonarr-instance.yaml --ignore-not-found + kubectl delete secret sonarr-api-key -n default --ignore-not-found + kubectl delete namespace sonarr-e2e-test --ignore-not-found + +##@ Complete Workflows + +.PHONY: all +all: lint test build crds ## Run lint, test, build, and generate CRDs + +.PHONY: ci +ci: lint test build ## CI pipeline tasks diff --git a/README.md b/README.md new file mode 100644 index 0000000..847147b --- /dev/null +++ b/README.md @@ -0,0 +1,263 @@ +# Sonarr Kubernetes Operator + +A Kubernetes operator for [Sonarr](https://sonarr.tv/) written in Rust using [kube-rs](https://kube.rs/). + +This operator allows you to manage Sonarr resources declaratively through Kubernetes Custom Resources, enabling GitOps workflows for TV series management. + +## Features + +- **Declarative Configuration**: Define Sonarr resources as Kubernetes manifests +- **GitOps Ready**: Manage Sonarr configuration through version control +- **Multi-Instance Support**: Manage multiple Sonarr instances from a single operator +- **Automatic Synchronization**: Resources are continuously reconciled with Sonarr +- **Finalizers**: Clean up resources in Sonarr when Kubernetes resources are deleted +- **19 CRDs**: Comprehensive coverage of Sonarr configuration options + +## Supported Resources + +The operator manages **19 CRDs** in the `devopsarr.io/v1alpha1` API group: + +### Main Instance +- **[Sonarr](docs/api/crd-reference.md#sonarr)** - The Sonarr server instance configuration + +### Content +- **[SonarrSeries](docs/api/crd-reference.md#sonarrseries)** - TV series management + +### Profiles +- **[SonarrQualityProfile](docs/api/crd-reference.md#sonarrqualityprofile)** - Quality profiles for downloads +- **[SonarrLanguageProfile](docs/api/crd-reference.md#sonarrlanguageprofile)** - Language preferences +- **[SonarrDelayProfile](docs/api/crd-reference.md#sonarrdelayprofile)** - Delay settings for releases + +### Integrations +- **[SonarrDownloadClient](docs/api/crd-reference.md#sonarrdownloadclient)** - Download clients (qBittorrent, SABnzbd, etc.) +- **[SonarrIndexer](docs/api/crd-reference.md#sonarrindexer)** - Indexers for searching torrents/usenet +- **[SonarrNotification](docs/api/crd-reference.md#sonarrnotification)** - Notifications (Discord, Telegram, etc.) +- **[SonarrImportList](docs/api/crd-reference.md#sonarrimportlist)** - Import lists for automatic series discovery + +### Organization +- **[SonarrTag](docs/api/crd-reference.md#sonarrtag)** - Tags for organizing series +- **[SonarrAutoTag](docs/api/crd-reference.md#sonarrautotag)** - Automatic tagging rules +- **[SonarrRootFolder](docs/api/crd-reference.md#sonarrrootfolder)** - Root folders for media storage + +### Quality +- **[SonarrQualityDefinition](docs/api/crd-reference.md#sonarrqualitydefinition)** - Quality definitions +- **[SonarrCustomFormat](docs/api/crd-reference.md#sonarrcustomformat)** - Custom format specifications + +### Metadata +- **[SonarrMetadata](docs/api/crd-reference.md#sonarrmetadata)** - Metadata providers + +### Config (Singletons per instance) +- **[SonarrMediaManagementConfig](docs/api/crd-reference.md#sonarrmediamanagementconfig)** - File management settings +- **[SonarrNamingConfig](docs/api/crd-reference.md#sonarrnamingconfig)** - Episode/series naming patterns +- **[SonarrIndexerConfig](docs/api/crd-reference.md#sonarrindexerconfig)** - Global indexer settings +- **[SonarrDownloadClientConfig](docs/api/crd-reference.md#sonarrdownloadclientconfig)** - Global download client settings + +For detailed API specifications, see the [CRD Reference](docs/api/crd-reference.md). + +## Quick Start + +### Prerequisites + +- Kubernetes cluster (1.28+) +- [Helm](https://helm.sh/) 3.8+ (for OCI registry support) +- [k3d](https://k3d.io/) for local development (optional) + +### Installation (Helm — recommended) + +Install the operator and its CRDs from the OCI chart on GHCR: + +```bash +helm install sonarr-operator \ + oci://ghcr.io/devopsarr/charts/sonarr-operator \ + --namespace sonarr-operator-system \ + --create-namespace +``` + +The chart installs the operator Deployment, RBAC, and all 19 CRDs. CRDs are annotated `helm.sh/resource-policy: keep`, so they (and any `Sonarr` resources) survive a `helm uninstall`. + +To install a specific version, pass `--version `. To skip CRD installation (when managing them out-of-band), pass `--set crds.install=false`. To allow `helm uninstall` to remove CRDs as well, pass `--set crds.keep=false`. See the [chart README](charts/sonarr-operator/README.md) for the full values reference. + +### Installation (raw manifests — alternative) + +Render the chart locally or use the per-release CRD asset: + +```bash +# Option A: render from chart source +helm template sonarr-operator charts/sonarr-operator \ + --namespace sonarr-operator-system | kubectl apply -f - + +# Option B: download CRDs from a release and apply the static deploy/ manifests +kubectl apply -f https://github.com/devopsarr/k8s-operator-sonarr/releases/latest/download/crds.yaml +kubectl apply -f deploy/namespace.yaml +kubectl apply -f deploy/rbac.yaml +kubectl apply -f deploy/deployment.yaml +``` + +### Create a Sonarr instance + +A minimal example lives at [deploy/examples/sonarr-minimal.yaml](deploy/examples/sonarr-minimal.yaml). + +```bash +# Optional: pre-create an API key Secret (omit to have the operator generate one) +kubectl create secret generic sonarr-api-key \ + --from-literal=api-key="$(openssl rand -hex 16)" + +kubectl apply -f deploy/examples/sonarr-minimal.yaml +kubectl wait sonarr/sonarr --for=condition=Ready --timeout=5m +``` + +### Create child resources + +```yaml +apiVersion: devopsarr.io/v1alpha1 +kind: SonarrTag +metadata: + name: anime +spec: + sonarrInstanceRef: + name: sonarr + label: "anime" +--- +apiVersion: devopsarr.io/v1alpha1 +kind: SonarrRootFolder +metadata: + name: tv-shows +spec: + sonarrInstanceRef: + name: my-sonarr + path: "/media/tv" +``` + +## Development + +### Building + +```bash +make build # Release binary +make build-debug # Debug binary +make crds # Generate CRD manifests +make docs # Generate CRD documentation +``` + +### Linting & Testing + +```bash +make lint # Format check + clippy +make test # Unit tests +make integration-test # Integration tests (requires cluster with CRDs) +``` + +### Local E2E Testing + +The local E2E workflow uses [k3d](https://k3d.io/) to create a cluster with Sonarr: + +```bash +# Terminal 1: Create cluster + deploy Sonarr +make e2e-up + +# Terminal 2: Run the operator locally +make run-debug + +# Terminal 3: Run E2E tests +make e2e + +# Cleanup +make e2e-down +``` + +See [docs/TESTING.md](docs/TESTING.md) for more details. + +### Deploy to a Local Cluster + +```bash +make deploy # Build image, import into k3d, deploy operator +make undeploy # Remove operator from cluster +``` + +## Architecture + +The operator implements the [Kubernetes Operator pattern](https://kubernetes.io/docs/concepts/extend-kubernetes/operator/) to declaratively manage Sonarr configuration. It runs as a Deployment in the cluster and watches 19 CRDs in the `devopsarr.io/v1alpha1` API group. + +### How It Works + +``` + Kubernetes Cluster +┌─────────────────────────────────────────────────────────────┐ +│ │ +│ ┌──────────────┐ watches ┌───────────────────────────┐ │ +│ │ Sonarr │◄───────────│ Sonarr Operator │ │ +│ │ CRDs (19) │ │ (Rust / kube-rs) │ │ +│ │ │ │ │ │ +│ │ devopsarr.io │ status │ ┌───────────────────────┐ │ │ +│ │ /v1alpha1 │◄───────────│ │ 19 Controllers │ │ │ +│ └──────────────┘ updates │ │ (one per CRD type) │ │ │ +│ │ └───────────┬───────────┘ │ │ +│ ┌──────────────┐ └─────────────┼─────────────┘ │ +│ │ K8s Secrets │ │ │ +│ │ (API keys) │──────────────────────────┤ │ +│ └──────────────┘ credentials │ │ +│ │ HTTP REST API │ +│ │ (v3/v4) │ +│ ▼ │ +│ ┌───────────────────────────┐ │ +│ │ Sonarr Instance(s) │ │ +│ │ (Pods / Services) │ │ +│ └───────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Resource Hierarchy + +All sub-resource CRDs reference a parent **Sonarr** instance via `sonarrInstanceRef`: + +``` +Sonarr (instance connection: URL + API key) +├── Content: SonarrSeries +├── Profiles: SonarrQualityProfile, SonarrLanguageProfile, SonarrDelayProfile +├── Integrations: SonarrDownloadClient, SonarrIndexer, SonarrNotification, SonarrImportList +├── Organization: SonarrTag, SonarrAutoTag, SonarrRootFolder +├── Quality: SonarrQualityDefinition, SonarrCustomFormat +├── Metadata: SonarrMetadata +└── Config: SonarrMediaManagementConfig, SonarrNamingConfig, + SonarrIndexerConfig, SonarrDownloadClientConfig +``` + +### Reconciliation Loop + +Each controller runs an independent reconciliation loop: + +1. **Watch** — Detect create/update/delete events on the CRD +2. **Resolve** — Look up the `SonarrInstanceRef` to get URL and API key from the Sonarr CR and its Secret +3. **Apply** — Call the Sonarr REST API to create or update the resource +4. **Status** — Write the Sonarr resource ID and a `Ready` condition back to the CRD status +5. **Finalize** — On deletion, remove the resource from Sonarr before allowing the CR to be garbage-collected +6. **Requeue** — Re-reconcile every 5 minutes to catch out-of-band changes (errors requeue after 60 seconds) + +## Configuration + +### Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `RUST_LOG` | Log level (trace, debug, info, warn, error) | `info` | + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +1. Fork the repository +2. Create your feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes using [Conventional Commits](https://www.conventionalcommits.org/) (`git commit -m 'feat: add amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request + +## License + +This project is licensed under the GPL-3.0 License - see the [LICENSE](LICENSE) file for details. + +## Acknowledgments + +- [Sonarr](https://sonarr.tv/) - The PVR for Usenet and BitTorrent users +- [kube-rs](https://kube.rs/) - Rust client for Kubernetes +- [sonarr-rs](https://github.com/devopsarr/sonarr-rs) - Sonarr API client for Rust diff --git a/charts/sonarr-operator/.helmignore b/charts/sonarr-operator/.helmignore new file mode 100644 index 0000000..9fc7a8a --- /dev/null +++ b/charts/sonarr-operator/.helmignore @@ -0,0 +1,20 @@ +# Patterns to ignore when building Helm packages. +.DS_Store +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +*.swp +*.bak +*.tmp +*.orig +*~ +.idea/ +.vscode/ +.project +.tox/ +.flake8 +.pytest_cache/ diff --git a/charts/sonarr-operator/Chart.yaml b/charts/sonarr-operator/Chart.yaml new file mode 100644 index 0000000..48e68d7 --- /dev/null +++ b/charts/sonarr-operator/Chart.yaml @@ -0,0 +1,29 @@ +apiVersion: v2 +name: sonarr-operator +description: A Kubernetes operator for managing Sonarr instances and their + configuration via Custom Resources +type: application +# Chart version is bumped lockstep with the operator binary by release-please +# (linked-versions plugin). `appVersion` tracks the operator's crate version. +version: 0.1.0 +appVersion: "0.1.0" # x-release-please-version +kubeVersion: ">=1.28.0-0" +home: https://github.com/devopsarr/k8s-operator-sonarr +sources: + - https://github.com/devopsarr/k8s-operator-sonarr +maintainers: + - name: DevOpsArr + url: https://github.com/devopsarr +keywords: + - sonarr + - operator + - kubernetes + - kube-rs + - media + - arr +annotations: + artifacthub.io/category: integration-delivery + artifacthub.io/license: GPL-3.0 + artifacthub.io/operator: "true" + artifacthub.io/operatorCapabilities: Basic Install + artifacthub.io/prerelease: "true" diff --git a/charts/sonarr-operator/README.md b/charts/sonarr-operator/README.md new file mode 100644 index 0000000..3ef9c21 --- /dev/null +++ b/charts/sonarr-operator/README.md @@ -0,0 +1,81 @@ +# sonarr-operator + +A Kubernetes operator that manages [Sonarr](https://sonarr.tv/) instances and their configuration declaratively through Custom Resources. + +This chart installs the operator Deployment, RBAC, and (optionally) the CRDs that the operator reconciles. + +## TL;DR + +```bash +helm install sonarr-operator \ + oci://ghcr.io/devopsarr/charts/sonarr-operator \ + --namespace sonarr-operator-system \ + --create-namespace +``` + +## Prerequisites + +- Kubernetes >= 1.28 +- Helm >= 3.8 (OCI support) + +## Installing the chart + +```bash +helm install sonarr-operator \ + oci://ghcr.io/devopsarr/charts/sonarr-operator \ + --version \ + --namespace sonarr-operator-system \ + --create-namespace +``` + +The chart ships **19 CRDs** under the `devopsarr.io/v1alpha1` API group. They are installed by default and annotated `helm.sh/resource-policy: keep`, so they (and your `Sonarr` resources) survive a `helm uninstall`. + +If you manage CRDs out-of-band (for example with a separate Flux/ArgoCD application), disable them: + +```bash +helm install sonarr-operator oci://ghcr.io/devopsarr/charts/sonarr-operator \ + --set crds.install=false +``` + +To allow `helm uninstall` to remove CRDs as well, opt out of the `keep` policy: + +```bash +helm install sonarr-operator oci://ghcr.io/devopsarr/charts/sonarr-operator \ + --set crds.keep=false +``` + +## Uninstalling the chart + +```bash +helm uninstall sonarr-operator -n sonarr-operator-system +``` + +CRDs are intentionally **not** removed by `helm uninstall`. To purge them and all managed resources: + +```bash +kubectl get crd -o name | grep devopsarr.io | xargs kubectl delete +``` + +## Values + +See [`values.yaml`](./values.yaml) for the full set of values with inline documentation. + +| Key | Default | Description | +|---|---|---| +| `replicaCount` | `1` | Number of operator replicas. | +| `image.repository` | `ghcr.io/devopsarr/k8s-operator-sonarr` | Operator image. | +| `image.tag` | `""` | Image tag. Defaults to `Chart.AppVersion`. | +| `image.pullPolicy` | `IfNotPresent` | Image pull policy. | +| `crds.install` | `true` | Install the operator's CRDs as part of the chart. | +| `crds.keep` | `true` | Annotate CRDs with `helm.sh/resource-policy: keep`. | +| `crds.annotations` | `{}` | Extra annotations merged into each CRD. | +| `crds.additionalLabels` | `{}` | Extra labels merged into each CRD. | +| `serviceAccount.create` | `true` | Create a ServiceAccount for the operator. | +| `rbac.create` | `true` | Create ClusterRole and ClusterRoleBinding. | +| `logLevel` | `info,sonarr_operator=debug` | `RUST_LOG` value. | +| `resources` | requests `50m`/`64Mi`, limits `200m`/`256Mi` | Container resources. | + +## Source + +- Operator: +- CRD reference: diff --git a/charts/sonarr-operator/artifacthub-repo.yml b/charts/sonarr-operator/artifacthub-repo.yml new file mode 100644 index 0000000..e6a7e4e --- /dev/null +++ b/charts/sonarr-operator/artifacthub-repo.yml @@ -0,0 +1,4 @@ +repositoryID: "" +owners: + - name: DevOpsArr + email: devopsarr@users.noreply.github.com diff --git a/charts/sonarr-operator/templates/NOTES.txt b/charts/sonarr-operator/templates/NOTES.txt new file mode 100644 index 0000000..ce803b2 --- /dev/null +++ b/charts/sonarr-operator/templates/NOTES.txt @@ -0,0 +1,16 @@ +{{ .Chart.Name | upper }} {{ .Chart.Version }} installed in namespace {{ .Release.Namespace }}. + +Operator image: {{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }} + +Next steps: + 1. Verify the operator is running: + kubectl -n {{ .Release.Namespace }} rollout status deploy/{{ include "sonarr-operator.fullname" . }} + + 2. Create a Sonarr instance. A minimal example is available at: + https://github.com/devopsarr/k8s-operator-sonarr/blob/main/deploy/examples/sonarr-minimal.yaml + + 3. Apply it (after creating the referenced API key Secret): + kubectl apply -f sonarr-minimal.yaml + kubectl wait sonarr/sonarr --for=condition=Ready --timeout=5m + +CRD installation: {{ if .Values.crds.install }}enabled{{ if .Values.crds.keep }} (annotated `helm.sh/resource-policy: keep`){{ end }}.{{ else }}DISABLED — make sure CRDs are installed out-of-band before applying any Sonarr CR.{{ end }} diff --git a/charts/sonarr-operator/templates/_helpers.tpl b/charts/sonarr-operator/templates/_helpers.tpl new file mode 100644 index 0000000..c528dc7 --- /dev/null +++ b/charts/sonarr-operator/templates/_helpers.tpl @@ -0,0 +1,62 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "sonarr-operator.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a fully qualified app name. +*/}} +{{- define "sonarr-operator.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Chart name + version label. +*/}} +{{- define "sonarr-operator.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels. +*/}} +{{- define "sonarr-operator.labels" -}} +helm.sh/chart: {{ include "sonarr-operator.chart" . }} +{{ include "sonarr-operator.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +app.kubernetes.io/component: operator +app.kubernetes.io/part-of: sonarr-operator +{{- end }} + +{{/* +Selector labels. +*/}} +{{- define "sonarr-operator.selectorLabels" -}} +app.kubernetes.io/name: {{ include "sonarr-operator.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Name of the ServiceAccount to use. +*/}} +{{- define "sonarr-operator.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "sonarr-operator.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/charts/sonarr-operator/templates/clusterrole.yaml b/charts/sonarr-operator/templates/clusterrole.yaml new file mode 100644 index 0000000..ba96ccf --- /dev/null +++ b/charts/sonarr-operator/templates/clusterrole.yaml @@ -0,0 +1,44 @@ +{{- if .Values.rbac.create -}} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ include "sonarr-operator.fullname" . }} + labels: + {{- include "sonarr-operator.labels" . | nindent 4 }} +rules: + # Core API resources + - apiGroups: [""] + resources: ["secrets"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: [""] + resources: ["services"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: [""] + resources: ["persistentvolumeclaims"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: [""] + resources: ["events"] + verbs: ["create", "patch"] + # Apps API + - apiGroups: ["apps"] + resources: ["deployments"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + # Networking API (Ingress) + - apiGroups: ["networking.k8s.io"] + resources: ["ingresses"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + # Gateway API (HTTPRoute) + - apiGroups: ["gateway.networking.k8s.io"] + resources: ["httproutes"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + # Sonarr CRDs + - apiGroups: ["devopsarr.io"] + resources: ["*"] + verbs: ["*"] + - apiGroups: ["devopsarr.io"] + resources: ["*/status"] + verbs: ["get", "patch", "update"] + - apiGroups: ["devopsarr.io"] + resources: ["*/finalizers"] + verbs: ["update"] +{{- end }} diff --git a/charts/sonarr-operator/templates/clusterrolebinding.yaml b/charts/sonarr-operator/templates/clusterrolebinding.yaml new file mode 100644 index 0000000..80dd487 --- /dev/null +++ b/charts/sonarr-operator/templates/clusterrolebinding.yaml @@ -0,0 +1,16 @@ +{{- if .Values.rbac.create -}} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ include "sonarr-operator.fullname" . }} + labels: + {{- include "sonarr-operator.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ include "sonarr-operator.fullname" . }} +subjects: + - kind: ServiceAccount + name: {{ include "sonarr-operator.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} +{{- end }} diff --git a/charts/sonarr-operator/templates/crds/autotag.yaml b/charts/sonarr-operator/templates/crds/autotag.yaml new file mode 100644 index 0000000..e8ca6e7 --- /dev/null +++ b/charts/sonarr-operator/templates/crds/autotag.yaml @@ -0,0 +1,197 @@ +{{- if .Values.crds.install }} +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + {{- if or .Values.crds.keep .Values.crds.annotations }} + annotations: + {{- if .Values.crds.keep }} + helm.sh/resource-policy: keep + {{- end }} + {{- with .Values.crds.annotations }} +{{ toYaml . | indent 4 }} + {{- end }} + {{- end }} + {{- with .Values.crds.additionalLabels }} + labels: +{{ toYaml . | indent 4 }} + {{- end }} + name: sonarrautotags.devopsarr.io +spec: + group: devopsarr.io + names: + categories: [] + kind: SonarrAutoTag + plural: sonarrautotags + shortNames: + - sat + singular: sonarrautotag + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.name + name: Name + type: string + - jsonPath: .spec.removeTagsAutomatically + name: RemoveAuto + type: boolean + - jsonPath: .status.id + name: ID + type: integer + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Auto-generated derived type for SonarrAutoTagSpec via `CustomResource` + properties: + spec: + description: |- + SonarrAutoTag represents an auto-tagging rule configuration in Sonarr + Auto-tagging automatically applies tags to series based on conditions + properties: + name: + description: Auto-tag rule name + type: string + removeTagsAutomatically: + default: false + description: Remove tags automatically when conditions no longer match + type: boolean + sonarrInstanceRef: + description: Reference to the SonarrInstance + properties: + name: + default: '' + description: Name of the SonarrInstance resource + type: string + namespace: + description: Namespace of the SonarrInstance (optional, defaults to same namespace) + nullable: true + type: string + type: object + specifications: + default: [] + description: Specifications (conditions) for this auto-tag rule + items: + properties: + fields: + default: + max: null + min: null + value: null + description: Fields/values for this specification + properties: + max: + description: Maximum value (for year specifications) + format: int32 + nullable: true + type: integer + min: + description: Minimum value (for year specifications) + format: int32 + nullable: true + type: integer + value: + description: Value for the specification (path, genre, network, etc.) + nullable: true + type: string + type: object + implementation: + description: Specification type/implementation + enum: + - rootFolderSpecification + - genreSpecification + - yearSpecification + - seriesTypeSpecification + - qualityProfileSpecification + - networkSpecification + - originalLanguageSpecification + - tagSpecification + type: string + name: + description: Specification name + type: string + negate: + default: false + description: Negate this condition + type: boolean + required: + default: true + description: This condition is required + type: boolean + required: + - implementation + - name + type: object + type: array + tags: + default: [] + description: Tags to apply when conditions match + items: + format: int32 + type: integer + type: array + required: + - name + - sonarrInstanceRef + type: object + status: + nullable: true + properties: + conditions: + default: [] + description: Current conditions + items: + description: Condition contains details for one aspect of the current state of this API Resource. + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating details about the transition. This may be an empty string. + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance. + format: int64 + type: integer + reason: + description: reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty. + type: string + status: + description: status of the condition, one of True, False, Unknown. + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + id: + description: Sonarr Auto Tag ID + format: int32 + nullable: true + type: integer + observedGeneration: + default: 0 + description: Observed generation + format: int64 + type: integer + type: object + required: + - spec + title: SonarrAutoTag + type: object + served: true + storage: true + subresources: + status: {} +{{- end }} diff --git a/charts/sonarr-operator/templates/crds/customformat.yaml b/charts/sonarr-operator/templates/crds/customformat.yaml new file mode 100644 index 0000000..8ba3510 --- /dev/null +++ b/charts/sonarr-operator/templates/crds/customformat.yaml @@ -0,0 +1,188 @@ +{{- if .Values.crds.install }} +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + {{- if or .Values.crds.keep .Values.crds.annotations }} + annotations: + {{- if .Values.crds.keep }} + helm.sh/resource-policy: keep + {{- end }} + {{- with .Values.crds.annotations }} +{{ toYaml . | indent 4 }} + {{- end }} + {{- end }} + {{- with .Values.crds.additionalLabels }} + labels: +{{ toYaml . | indent 4 }} + {{- end }} + name: sonarrcustomformats.devopsarr.io +spec: + group: devopsarr.io + names: + categories: [] + kind: SonarrCustomFormat + plural: sonarrcustomformats + shortNames: + - scf + singular: sonarrcustomformat + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.name + name: Name + type: string + - jsonPath: .status.id + name: ID + type: integer + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Auto-generated derived type for SonarrCustomFormatSpec via `CustomResource` + properties: + spec: + description: |- + SonarrCustomFormat represents a custom format configuration in Sonarr + Custom formats are used to score releases based on various criteria + properties: + includeCustomFormatWhenRenaming: + default: false + description: Include custom format name when renaming files + type: boolean + name: + description: Custom format name + type: string + sonarrInstanceRef: + description: Reference to the SonarrInstance + properties: + name: + default: '' + description: Name of the SonarrInstance resource + type: string + namespace: + description: Namespace of the SonarrInstance (optional, defaults to same namespace) + nullable: true + type: string + type: object + specifications: + default: [] + description: Specifications (conditions) for this custom format + items: + properties: + fields: + default: + max: null + min: null + value: null + description: Fields/values for this specification + properties: + max: + description: Maximum value (for size specifications) + format: double + nullable: true + type: number + min: + description: Minimum value (for size specifications) + format: double + nullable: true + type: number + value: + description: Value for the specification (regex pattern, source type, etc.) + nullable: true + type: string + type: object + implementation: + description: Specification type/implementation + enum: + - releaseTitleSpecification + - sourceSpecification + - resolutionSpecification + - qualityModifierSpecification + - sizeSpecification + - indexerFlagSpecification + - languageSpecification + - releaseGroupSpecification + - editionSpecification + type: string + name: + description: Specification name + type: string + negate: + default: false + description: Negate this condition + type: boolean + required: + default: true + description: This condition is required + type: boolean + required: + - implementation + - name + type: object + type: array + required: + - name + - sonarrInstanceRef + type: object + status: + nullable: true + properties: + conditions: + default: [] + description: Current conditions + items: + description: Condition contains details for one aspect of the current state of this API Resource. + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating details about the transition. This may be an empty string. + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance. + format: int64 + type: integer + reason: + description: reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty. + type: string + status: + description: status of the condition, one of True, False, Unknown. + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + id: + description: Sonarr Custom Format ID + format: int32 + nullable: true + type: integer + observedGeneration: + default: 0 + description: Observed generation + format: int64 + type: integer + type: object + required: + - spec + title: SonarrCustomFormat + type: object + served: true + storage: true + subresources: + status: {} +{{- end }} diff --git a/charts/sonarr-operator/templates/crds/delayprofile.yaml b/charts/sonarr-operator/templates/crds/delayprofile.yaml new file mode 100644 index 0000000..ac8d7eb --- /dev/null +++ b/charts/sonarr-operator/templates/crds/delayprofile.yaml @@ -0,0 +1,180 @@ +{{- if .Values.crds.install }} +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + {{- if or .Values.crds.keep .Values.crds.annotations }} + annotations: + {{- if .Values.crds.keep }} + helm.sh/resource-policy: keep + {{- end }} + {{- with .Values.crds.annotations }} +{{ toYaml . | indent 4 }} + {{- end }} + {{- end }} + {{- with .Values.crds.additionalLabels }} + labels: +{{ toYaml . | indent 4 }} + {{- end }} + name: sonarrdelayprofiles.devopsarr.io +spec: + group: devopsarr.io + names: + categories: [] + kind: SonarrDelayProfile + plural: sonarrdelayprofiles + shortNames: + - sdp + singular: sonarrdelayprofile + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.preferredProtocol + name: Protocol + type: string + - jsonPath: .spec.usenetDelay + name: UsenetDelay + type: integer + - jsonPath: .spec.torrentDelay + name: TorrentDelay + type: integer + - jsonPath: .status.id + name: ID + type: integer + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Auto-generated derived type for SonarrDelayProfileSpec via `CustomResource` + properties: + spec: + description: |- + SonarrDelayProfile represents a delay profile configuration in Sonarr + Delay profiles control how long Sonarr waits before grabbing a release + properties: + bypassIfAboveCustomFormatScore: + default: false + description: Bypass delay if above custom format score + type: boolean + bypassIfHighestQuality: + default: false + description: Bypass delay if highest quality + type: boolean + enableTorrent: + default: true + description: Enable Torrent downloads + type: boolean + enableUsenet: + default: true + description: Enable Usenet downloads + type: boolean + minimumCustomFormatScore: + default: 0 + description: Minimum custom format score to bypass delay + format: int32 + type: integer + order: + default: 0 + description: Order of this profile (lower = higher priority) + format: int32 + type: integer + preferredProtocol: + default: usenet + description: Preferred download protocol + enum: + - usenet + - torrent + type: string + sonarrInstanceRef: + description: Reference to the SonarrInstance + properties: + name: + default: '' + description: Name of the SonarrInstance resource + type: string + namespace: + description: Namespace of the SonarrInstance (optional, defaults to same namespace) + nullable: true + type: string + type: object + tags: + default: [] + description: Tags to apply this delay profile to + items: + format: int32 + type: integer + type: array + torrentDelay: + default: 0 + description: Delay for Torrents in minutes + format: int32 + type: integer + usenetDelay: + default: 0 + description: Delay for Usenet in minutes + format: int32 + type: integer + required: + - sonarrInstanceRef + type: object + status: + nullable: true + properties: + conditions: + default: [] + description: Current conditions + items: + description: Condition contains details for one aspect of the current state of this API Resource. + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating details about the transition. This may be an empty string. + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance. + format: int64 + type: integer + reason: + description: reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty. + type: string + status: + description: status of the condition, one of True, False, Unknown. + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + id: + description: Sonarr Delay Profile ID + format: int32 + nullable: true + type: integer + observedGeneration: + default: 0 + description: Observed generation + format: int64 + type: integer + type: object + required: + - spec + title: SonarrDelayProfile + type: object + served: true + storage: true + subresources: + status: {} +{{- end }} diff --git a/charts/sonarr-operator/templates/crds/downloadclient.yaml b/charts/sonarr-operator/templates/crds/downloadclient.yaml new file mode 100644 index 0000000..e27aab2 --- /dev/null +++ b/charts/sonarr-operator/templates/crds/downloadclient.yaml @@ -0,0 +1,304 @@ +{{- if .Values.crds.install }} +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + {{- if or .Values.crds.keep .Values.crds.annotations }} + annotations: + {{- if .Values.crds.keep }} + helm.sh/resource-policy: keep + {{- end }} + {{- with .Values.crds.annotations }} +{{ toYaml . | indent 4 }} + {{- end }} + {{- end }} + {{- with .Values.crds.additionalLabels }} + labels: +{{ toYaml . | indent 4 }} + {{- end }} + name: sonarrdownloadclients.devopsarr.io +spec: + group: devopsarr.io + names: + categories: [] + kind: SonarrDownloadClient + plural: sonarrdownloadclients + shortNames: + - sdc + singular: sonarrdownloadclient + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.name + name: Name + type: string + - jsonPath: .spec.downloadClientType + name: Type + type: string + - jsonPath: .status.id + name: ID + type: integer + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Auto-generated derived type for SonarrDownloadClientSpec via `CustomResource` + properties: + spec: + description: |- + SonarrDownloadClient represents a download client configuration in Sonarr + Download clients are used to download releases (qBittorrent, Transmission, SABnzbd, etc.) + properties: + config: + description: Download client configuration + properties: + addPaused: + default: false + description: Add paused + type: boolean + apiKeySecretRef: + description: API key from secret (for some clients) + nullable: true + properties: + key: + description: Key in the secret + type: string + name: + description: Name of the secret + type: string + required: + - key + - name + type: object + firstAndLast: + default: false + description: First and last (for qBittorrent) + type: boolean + host: + description: Host address + nullable: true + type: string + initialState: + description: 'Initial state (for qBittorrent: 0 = Start, 1 = ForceStart, 2 = Pause)' + format: int32 + nullable: true + type: integer + nzbFolder: + description: NZB folder (for blackhole) + nullable: true + type: string + olderTvPriority: + description: Older TV priority (0 = Last, 1 = First) + format: int32 + nullable: true + type: integer + passwordSecretRef: + description: Password from secret + nullable: true + properties: + key: + description: Key in the secret + type: string + name: + description: Name of the secret + type: string + required: + - key + - name + type: object + port: + description: Port number + format: int32 + nullable: true + type: integer + recentTvPriority: + description: Recent TV priority (0 = Last, 1 = First) + format: int32 + nullable: true + type: integer + rpcPath: + description: RPC path (for Aria2) + nullable: true + type: string + saveMagnetFiles: + default: false + description: Save magnet files (for blackhole) + type: boolean + secretTokenSecretRef: + description: Secret token (for Aria2) + nullable: true + properties: + key: + description: Key in the secret + type: string + name: + description: Name of the secret + type: string + required: + - key + - name + type: object + sequentialOrder: + default: false + description: Sequential order (for qBittorrent) + type: boolean + strmFolder: + description: Strm folder (for pneumatic) + nullable: true + type: string + torrentFolder: + description: Torrent folder (for blackhole) + nullable: true + type: string + tvCategory: + description: TV category + nullable: true + type: string + tvDirectory: + description: TV directory + nullable: true + type: string + urlBase: + description: URL base path + nullable: true + type: string + useSsl: + default: false + description: Use SSL + type: boolean + username: + description: Username + nullable: true + type: string + watchFolder: + description: Watch folder (for blackhole) + nullable: true + type: string + type: object + downloadClientType: + description: Download client type + enum: + - Aria2 + - Deluge + - Flood + - Hadouken + - Nzbget + - Nzbvortex + - Pneumatic + - QBittorrent + - RTorrent + - Sabnzbd + - TorrentBlackhole + - TorrentDownloadStation + - Transmission + - UsenetBlackhole + - UsenetDownloadStation + - UTorrent + - Vuze + type: string + enable: + default: true + description: Enable this download client + type: boolean + name: + description: Download client name + type: string + priority: + default: 1 + description: Priority for this download client + format: int32 + type: integer + removeCompletedDownloads: + default: true + description: Remove completed downloads + type: boolean + removeFailedDownloads: + default: true + description: Remove failed downloads + type: boolean + sonarrInstanceRef: + description: Reference to the SonarrInstance + properties: + name: + default: '' + description: Name of the SonarrInstance resource + type: string + namespace: + description: Namespace of the SonarrInstance (optional, defaults to same namespace) + nullable: true + type: string + type: object + tags: + default: [] + description: Tags for this download client + items: + format: int32 + type: integer + type: array + required: + - config + - downloadClientType + - name + - sonarrInstanceRef + type: object + status: + nullable: true + properties: + conditions: + default: [] + description: Current conditions + items: + description: Condition contains details for one aspect of the current state of this API Resource. + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating details about the transition. This may be an empty string. + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance. + format: int64 + type: integer + reason: + description: reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty. + type: string + status: + description: status of the condition, one of True, False, Unknown. + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + id: + description: Sonarr Download Client ID + format: int32 + nullable: true + type: integer + observedGeneration: + default: 0 + description: Observed generation + format: int64 + type: integer + type: object + required: + - spec + title: SonarrDownloadClient + type: object + served: true + storage: true + subresources: + status: {} +{{- end }} diff --git a/charts/sonarr-operator/templates/crds/downloadclientconfig.yaml b/charts/sonarr-operator/templates/crds/downloadclientconfig.yaml new file mode 100644 index 0000000..a27b15e --- /dev/null +++ b/charts/sonarr-operator/templates/crds/downloadclientconfig.yaml @@ -0,0 +1,136 @@ +{{- if .Values.crds.install }} +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + {{- if or .Values.crds.keep .Values.crds.annotations }} + annotations: + {{- if .Values.crds.keep }} + helm.sh/resource-policy: keep + {{- end }} + {{- with .Values.crds.annotations }} +{{ toYaml . | indent 4 }} + {{- end }} + {{- end }} + {{- with .Values.crds.additionalLabels }} + labels: +{{ toYaml . | indent 4 }} + {{- end }} + name: sonarrdownloadclientconfigs.devopsarr.io +spec: + group: devopsarr.io + names: + categories: [] + kind: SonarrDownloadClientConfig + plural: sonarrdownloadclientconfigs + shortNames: + - sdcc + singular: sonarrdownloadclientconfig + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.sonarrInstanceRef.name + name: Instance + type: string + - jsonPath: .spec.enableCompletedDownloadHandling + name: Completed Handling + type: boolean + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Auto-generated derived type for SonarrDownloadClientConfigSpec via `CustomResource` + properties: + spec: + description: |- + SonarrDownloadClientConfig configures global download client settings for a Sonarr instance. + Only one SonarrDownloadClientConfig per Sonarr instance is allowed. + Note: This is different from SonarrDownloadClient which configures individual download clients. + properties: + autoRedownloadFailed: + description: Automatically redownload failed releases + nullable: true + type: boolean + autoRedownloadFailedFromInteractiveSearch: + description: Automatically redownload failed releases from interactive search + nullable: true + type: boolean + downloadClientWorkingFolders: + description: Working folders for download client (container path mapping) + nullable: true + type: string + enableCompletedDownloadHandling: + description: Enable completed download handling + nullable: true + type: boolean + sonarrInstanceRef: + description: Reference to the Sonarr instance + properties: + name: + default: '' + description: Name of the SonarrInstance resource + type: string + namespace: + description: Namespace of the SonarrInstance (optional, defaults to same namespace) + nullable: true + type: string + type: object + required: + - sonarrInstanceRef + type: object + status: + nullable: true + properties: + conditions: + default: [] + description: Current conditions + items: + description: Condition contains details for one aspect of the current state of this API Resource. + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating details about the transition. This may be an empty string. + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance. + format: int64 + type: integer + reason: + description: reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty. + type: string + status: + description: status of the condition, one of True, False, Unknown. + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + observedGeneration: + default: 0 + description: Observed generation + format: int64 + type: integer + type: object + required: + - spec + title: SonarrDownloadClientConfig + type: object + served: true + storage: true + subresources: + status: {} +{{- end }} diff --git a/charts/sonarr-operator/templates/crds/importlist.yaml b/charts/sonarr-operator/templates/crds/importlist.yaml new file mode 100644 index 0000000..7075608 --- /dev/null +++ b/charts/sonarr-operator/templates/crds/importlist.yaml @@ -0,0 +1,277 @@ +{{- if .Values.crds.install }} +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + {{- if or .Values.crds.keep .Values.crds.annotations }} + annotations: + {{- if .Values.crds.keep }} + helm.sh/resource-policy: keep + {{- end }} + {{- with .Values.crds.annotations }} +{{ toYaml . | indent 4 }} + {{- end }} + {{- end }} + {{- with .Values.crds.additionalLabels }} + labels: +{{ toYaml . | indent 4 }} + {{- end }} + name: sonarrimportlists.devopsarr.io +spec: + group: devopsarr.io + names: + categories: [] + kind: SonarrImportList + plural: sonarrimportlists + shortNames: + - sil + singular: sonarrimportlist + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.name + name: Name + type: string + - jsonPath: .spec.listType + name: Type + type: string + - jsonPath: .status.id + name: ID + type: integer + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Auto-generated derived type for SonarrImportListSpec via `CustomResource` + properties: + spec: + description: |- + SonarrImportList represents an import list configuration in Sonarr + Import lists automatically add series from external sources (Trakt, Plex, etc.) + properties: + config: + default: + accessToken: null + apiKey: null + authUser: null + baseUrl: null + languageProfileId: null + listId: null + listname: null + profileIds: [] + tagIds: [] + traktListType: null + username: null + description: Import list configuration + properties: + accessToken: + description: Access token (for Trakt/Plex) + nullable: true + type: string + apiKey: + description: API key (for Sonarr import) + nullable: true + type: string + authUser: + description: Auth user (for Trakt) + nullable: true + type: string + baseUrl: + description: Base URL (for Sonarr import) + nullable: true + type: string + languageProfileId: + description: Language profile ID (deprecated in v4) + format: int32 + nullable: true + type: integer + listId: + description: List ID + nullable: true + type: string + listname: + description: List name/ID + nullable: true + type: string + profileIds: + default: [] + description: Profile IDs (for Sonarr import) + items: + format: int32 + type: integer + type: array + tagIds: + default: [] + description: Tag IDs (for Sonarr import) + items: + format: int32 + type: integer + type: array + traktListType: + description: Trakt list type + format: int32 + nullable: true + type: integer + username: + description: Username (for various services) + nullable: true + type: string + type: object + enableAutomaticAdd: + default: true + description: Enable automatic add + type: boolean + listOrder: + default: 0 + description: List order + format: int32 + type: integer + listType: + description: Import list type/implementation + enum: + - sonarrImport + - traktListImport + - traktUserImport + - traktPopularImport + - plexImport + - imdbListImport + - customImport + - simklImport + - aniListImport + - myAnimeListImport + type: string + monitorNewItems: + default: all + description: Monitor new items + enum: + - all + - none + type: string + name: + description: Import list name + type: string + qualityProfileId: + description: Quality profile ID to use + format: int32 + type: integer + rootFolderPath: + description: Root folder path for imported series + type: string + searchForMissingEpisodes: + default: false + description: Search for missing episodes when adding + type: boolean + seasonFolder: + default: true + description: Use season folders + type: boolean + seriesType: + default: standard + description: Series type + enum: + - standard + - daily + - anime + type: string + shouldMonitor: + default: all + description: Monitor type for imported series + enum: + - all + - future + - missing + - existing + - firstSeason + - latestSeason + - pilot + - monitorSpecials + - unmonitorSpecials + - none + type: string + sonarrInstanceRef: + description: Reference to the SonarrInstance + properties: + name: + default: '' + description: Name of the SonarrInstance resource + type: string + namespace: + description: Namespace of the SonarrInstance (optional, defaults to same namespace) + nullable: true + type: string + type: object + tags: + default: [] + description: Tags for imported series + items: + format: int32 + type: integer + type: array + required: + - listType + - name + - qualityProfileId + - rootFolderPath + - sonarrInstanceRef + type: object + status: + nullable: true + properties: + conditions: + default: [] + description: Current conditions + items: + description: Condition contains details for one aspect of the current state of this API Resource. + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating details about the transition. This may be an empty string. + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance. + format: int64 + type: integer + reason: + description: reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty. + type: string + status: + description: status of the condition, one of True, False, Unknown. + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + id: + description: Sonarr Import List ID + format: int32 + nullable: true + type: integer + observedGeneration: + default: 0 + description: Observed generation + format: int64 + type: integer + type: object + required: + - spec + title: SonarrImportList + type: object + served: true + storage: true + subresources: + status: {} +{{- end }} diff --git a/charts/sonarr-operator/templates/crds/indexer.yaml b/charts/sonarr-operator/templates/crds/indexer.yaml new file mode 100644 index 0000000..e93b88a --- /dev/null +++ b/charts/sonarr-operator/templates/crds/indexer.yaml @@ -0,0 +1,269 @@ +{{- if .Values.crds.install }} +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + {{- if or .Values.crds.keep .Values.crds.annotations }} + annotations: + {{- if .Values.crds.keep }} + helm.sh/resource-policy: keep + {{- end }} + {{- with .Values.crds.annotations }} +{{ toYaml . | indent 4 }} + {{- end }} + {{- end }} + {{- with .Values.crds.additionalLabels }} + labels: +{{ toYaml . | indent 4 }} + {{- end }} + name: sonarrindexers.devopsarr.io +spec: + group: devopsarr.io + names: + categories: [] + kind: SonarrIndexer + plural: sonarrindexers + shortNames: + - sidx + singular: sonarrindexer + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.name + name: Name + type: string + - jsonPath: .spec.indexerType + name: Type + type: string + - jsonPath: .status.id + name: ID + type: integer + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Auto-generated derived type for SonarrIndexerSpec via `CustomResource` + properties: + spec: + description: |- + SonarrIndexer represents an indexer configuration in Sonarr + Indexers are sources for finding releases (Newznab, Torznab, etc.) + properties: + config: + description: Indexer-specific configuration + properties: + additionalParameters: + description: Additional parameters + nullable: true + type: string + animeCategories: + default: [] + description: Anime categories + items: + format: int32 + type: integer + type: array + animeStandardFormatSearch: + default: false + description: Search anime in standard format + type: boolean + apiKey: + description: API key (can reference a secret) + nullable: true + type: string + apiKeySecretRef: + description: API key from secret reference + nullable: true + properties: + key: + description: Key in the secret + type: string + name: + description: Name of the secret + type: string + required: + - key + - name + type: object + apiPath: + description: 'API path (default: /api)' + nullable: true + type: string + baseUrl: + description: Base URL for the indexer + nullable: true + type: string + categories: + default: [] + description: Categories to search + items: + format: int32 + type: integer + type: array + cookie: + description: Cookie (for some indexers) + nullable: true + type: string + minimumSeeders: + description: Minimum seeders (for torrent indexers) + format: int32 + nullable: true + type: integer + passkey: + description: Passkey (for some indexers) + nullable: true + type: string + passwordSecretRef: + description: Password secret reference (for some indexers) + nullable: true + properties: + key: + description: Key in the secret + type: string + name: + description: Name of the secret + type: string + required: + - key + - name + type: object + seedRatio: + description: Seed ratio (for torrent indexers) + format: double + nullable: true + type: number + seedTime: + description: Seed time (for torrent indexers) + format: int32 + nullable: true + type: integer + username: + description: Username (for some indexers) + nullable: true + type: string + type: object + downloadClientId: + description: Download client ID to use + format: int32 + nullable: true + type: integer + enableAutomaticSearch: + default: true + description: Enable automatic search + type: boolean + enableInteractiveSearch: + default: true + description: Enable interactive search + type: boolean + enableRss: + default: true + description: Enable RSS feeds + type: boolean + indexerType: + description: Indexer type (Newznab, Torznab, etc.) + enum: + - newznab + - torznab + - fanzub + - broadcasthenet + - filelist + - hdbits + - iptorrents + - nyaa + - torrentrss + - torrentleech + type: string + name: + description: Indexer name + type: string + priority: + default: 25 + description: Priority for this indexer + format: int32 + type: integer + sonarrInstanceRef: + description: Reference to the SonarrInstance + properties: + name: + default: '' + description: Name of the SonarrInstance resource + type: string + namespace: + description: Namespace of the SonarrInstance (optional, defaults to same namespace) + nullable: true + type: string + type: object + tags: + default: [] + description: Tags for this indexer + items: + format: int32 + type: integer + type: array + required: + - config + - indexerType + - name + - sonarrInstanceRef + type: object + status: + nullable: true + properties: + conditions: + default: [] + description: Current conditions + items: + description: Condition contains details for one aspect of the current state of this API Resource. + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating details about the transition. This may be an empty string. + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance. + format: int64 + type: integer + reason: + description: reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty. + type: string + status: + description: status of the condition, one of True, False, Unknown. + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + id: + description: Sonarr Indexer ID + format: int32 + nullable: true + type: integer + observedGeneration: + default: 0 + description: Observed generation + format: int64 + type: integer + type: object + required: + - spec + title: SonarrIndexer + type: object + served: true + storage: true + subresources: + status: {} +{{- end }} diff --git a/charts/sonarr-operator/templates/crds/indexerconfig.yaml b/charts/sonarr-operator/templates/crds/indexerconfig.yaml new file mode 100644 index 0000000..b84f7a1 --- /dev/null +++ b/charts/sonarr-operator/templates/crds/indexerconfig.yaml @@ -0,0 +1,140 @@ +{{- if .Values.crds.install }} +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + {{- if or .Values.crds.keep .Values.crds.annotations }} + annotations: + {{- if .Values.crds.keep }} + helm.sh/resource-policy: keep + {{- end }} + {{- with .Values.crds.annotations }} +{{ toYaml . | indent 4 }} + {{- end }} + {{- end }} + {{- with .Values.crds.additionalLabels }} + labels: +{{ toYaml . | indent 4 }} + {{- end }} + name: sonarrindexerconfigs.devopsarr.io +spec: + group: devopsarr.io + names: + categories: [] + kind: SonarrIndexerConfig + plural: sonarrindexerconfigs + shortNames: + - sic + singular: sonarrindexerconfig + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.sonarrInstanceRef.name + name: Instance + type: string + - jsonPath: .spec.rssSyncInterval + name: RSS Interval + type: integer + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Auto-generated derived type for SonarrIndexerConfigSpec via `CustomResource` + properties: + spec: + description: |- + SonarrIndexerConfig configures global indexer settings for a Sonarr instance. + Only one SonarrIndexerConfig per Sonarr instance is allowed. + Note: This is different from SonarrIndexer which configures individual indexers. + properties: + maximumSize: + description: Maximum release size in MB (0 = unlimited) + format: int32 + nullable: true + type: integer + minimumAge: + description: Minimum age in minutes before downloading (usenet) + format: int32 + nullable: true + type: integer + retention: + description: Retention in days (0 = unlimited) + format: int32 + nullable: true + type: integer + rssSyncInterval: + description: RSS sync interval in minutes (0 = disabled, minimum 10) + format: int32 + nullable: true + type: integer + sonarrInstanceRef: + description: Reference to the Sonarr instance + properties: + name: + default: '' + description: Name of the SonarrInstance resource + type: string + namespace: + description: Namespace of the SonarrInstance (optional, defaults to same namespace) + nullable: true + type: string + type: object + required: + - sonarrInstanceRef + type: object + status: + nullable: true + properties: + conditions: + default: [] + description: Current conditions + items: + description: Condition contains details for one aspect of the current state of this API Resource. + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating details about the transition. This may be an empty string. + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance. + format: int64 + type: integer + reason: + description: reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty. + type: string + status: + description: status of the condition, one of True, False, Unknown. + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + observedGeneration: + default: 0 + description: Observed generation + format: int64 + type: integer + type: object + required: + - spec + title: SonarrIndexerConfig + type: object + served: true + storage: true + subresources: + status: {} +{{- end }} diff --git a/charts/sonarr-operator/templates/crds/languageprofile.yaml b/charts/sonarr-operator/templates/crds/languageprofile.yaml new file mode 100644 index 0000000..56cd8aa --- /dev/null +++ b/charts/sonarr-operator/templates/crds/languageprofile.yaml @@ -0,0 +1,260 @@ +{{- if .Values.crds.install }} +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + {{- if or .Values.crds.keep .Values.crds.annotations }} + annotations: + {{- if .Values.crds.keep }} + helm.sh/resource-policy: keep + {{- end }} + {{- with .Values.crds.annotations }} +{{ toYaml . | indent 4 }} + {{- end }} + {{- end }} + {{- with .Values.crds.additionalLabels }} + labels: +{{ toYaml . | indent 4 }} + {{- end }} + name: sonarrlanguageprofiles.devopsarr.io +spec: + group: devopsarr.io + names: + categories: [] + kind: SonarrLanguageProfile + plural: sonarrlanguageprofiles + shortNames: + - slp + singular: sonarrlanguageprofile + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.name + name: Name + type: string + - jsonPath: .spec.cutoffLanguage + name: Cutoff + type: string + - jsonPath: .status.id + name: ID + type: integer + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Auto-generated derived type for SonarrLanguageProfileSpec via `CustomResource` + properties: + spec: + description: |- + SonarrLanguageProfile represents a language profile configuration in Sonarr + Language profiles define preferred languages for downloading series + Note: Deprecated in Sonarr v4, replaced by per-series language selection + properties: + cutoffLanguage: + description: Cutoff language - stop upgrading when this language is reached + enum: + - Unknown + - English + - French + - Spanish + - German + - Italian + - Danish + - Dutch + - Japanese + - Icelandic + - Chinese + - Russian + - Polish + - Vietnamese + - Swedish + - Norwegian + - Finnish + - Turkish + - Portuguese + - Flemish + - Greek + - Korean + - Hungarian + - Hebrew + - Lithuanian + - Czech + - Hindi + - Romanian + - Thai + - Bulgarian + - PortugueseBrazil + - Arabic + - Ukrainian + - Persian + - Bengali + - Slovak + - Latvian + - SpanishLatino + - Catalan + - Croatian + - Serbian + - Bosnian + - Estonian + - Tamil + - Indonesian + - Telugu + - Macedonian + - Slovenian + - Malay + - Original + - Any + type: string + languages: + description: Ordered list of languages (first = highest priority) + items: + properties: + allowed: + default: true + description: Whether this language is allowed + type: boolean + language: + description: Language + enum: + - Unknown + - English + - French + - Spanish + - German + - Italian + - Danish + - Dutch + - Japanese + - Icelandic + - Chinese + - Russian + - Polish + - Vietnamese + - Swedish + - Norwegian + - Finnish + - Turkish + - Portuguese + - Flemish + - Greek + - Korean + - Hungarian + - Hebrew + - Lithuanian + - Czech + - Hindi + - Romanian + - Thai + - Bulgarian + - PortugueseBrazil + - Arabic + - Ukrainian + - Persian + - Bengali + - Slovak + - Latvian + - SpanishLatino + - Catalan + - Croatian + - Serbian + - Bosnian + - Estonian + - Tamil + - Indonesian + - Telugu + - Macedonian + - Slovenian + - Malay + - Original + - Any + type: string + required: + - language + type: object + type: array + name: + description: Language profile name + type: string + sonarrInstanceRef: + description: Reference to the SonarrInstance + properties: + name: + default: '' + description: Name of the SonarrInstance resource + type: string + namespace: + description: Namespace of the SonarrInstance (optional, defaults to same namespace) + nullable: true + type: string + type: object + upgradeAllowed: + default: false + description: Allow upgrades to better quality languages + type: boolean + required: + - cutoffLanguage + - languages + - name + - sonarrInstanceRef + type: object + status: + nullable: true + properties: + conditions: + default: [] + description: Current conditions + items: + description: Condition contains details for one aspect of the current state of this API Resource. + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating details about the transition. This may be an empty string. + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance. + format: int64 + type: integer + reason: + description: reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty. + type: string + status: + description: status of the condition, one of True, False, Unknown. + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + id: + description: Sonarr Language Profile ID + format: int32 + nullable: true + type: integer + observedGeneration: + default: 0 + description: Observed generation + format: int64 + type: integer + type: object + required: + - spec + title: SonarrLanguageProfile + type: object + served: true + storage: true + subresources: + status: {} +{{- end }} diff --git a/charts/sonarr-operator/templates/crds/mediamanagementconfig.yaml b/charts/sonarr-operator/templates/crds/mediamanagementconfig.yaml new file mode 100644 index 0000000..839175d --- /dev/null +++ b/charts/sonarr-operator/templates/crds/mediamanagementconfig.yaml @@ -0,0 +1,218 @@ +{{- if .Values.crds.install }} +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + {{- if or .Values.crds.keep .Values.crds.annotations }} + annotations: + {{- if .Values.crds.keep }} + helm.sh/resource-policy: keep + {{- end }} + {{- with .Values.crds.annotations }} +{{ toYaml . | indent 4 }} + {{- end }} + {{- end }} + {{- with .Values.crds.additionalLabels }} + labels: +{{ toYaml . | indent 4 }} + {{- end }} + name: sonarrmediamanagementconfigs.devopsarr.io +spec: + group: devopsarr.io + names: + categories: [] + kind: SonarrMediaManagementConfig + plural: sonarrmediamanagementconfigs + shortNames: + - smmc + singular: sonarrmediamanagementconfig + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.sonarrInstanceRef.name + name: Instance + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Auto-generated derived type for SonarrMediaManagementConfigSpec via `CustomResource` + properties: + spec: + description: |- + SonarrMediaManagementConfig configures media management settings for a Sonarr instance. + Only one SonarrMediaManagementConfig per Sonarr instance is allowed. + properties: + autoUnmonitorPreviouslyDownloadedEpisodes: + description: Auto unmonitor previously downloaded episodes when marked as deleted + nullable: true + type: boolean + chmodFolder: + description: chmod folder permissions (e.g., "755") + nullable: true + type: string + chownGroup: + description: chown group + nullable: true + type: string + copyUsingHardlinks: + description: Use hardlinks instead of copy when possible + nullable: true + type: boolean + createEmptySeriesFolders: + description: Create empty series folders during disk scan + nullable: true + type: boolean + deleteEmptyFolders: + description: Delete empty series and season folders during disk scan + nullable: true + type: boolean + downloadPropersAndRepacks: + description: 'Download propers and repacks: DoNotPrefer, PreferAndUpgrade, DoNotUpgrade' + enum: + - DoNotPrefer + - PreferAndUpgrade + - DoNotUpgrade + - null + nullable: true + type: string + enableMediaInfo: + description: Enable media info scanning + nullable: true + type: boolean + episodeTitleRequired: + description: 'Episode title required: Always, BulkSeasonReleases, Never' + enum: + - Always + - BulkSeasonReleases + - Never + - null + nullable: true + type: string + extraFileExtensions: + description: Extra file extensions to import (e.g., "srt,sub") + nullable: true + type: string + fileDate: + description: 'File date to use: None, LocalAirDate, UtcAirDate' + enum: + - None + - LocalAirDate + - UtcAirDate + - null + nullable: true + type: string + importExtraFiles: + description: Import extra files (subtitles, etc.) + nullable: true + type: boolean + minimumFreeSpaceWhenImporting: + description: Minimum free space when importing (MB) + format: int32 + nullable: true + type: integer + recycleBin: + description: Recycle bin path (empty to disable) + nullable: true + type: string + recycleBinCleanupDays: + description: Days to keep files in recycle bin before cleaning (0 to disable) + format: int32 + nullable: true + type: integer + rescanAfterRefresh: + description: 'Rescan series folder after refresh: Always, AfterManual, Never' + enum: + - Always + - AfterManual + - Never + - null + nullable: true + type: string + scriptImportPath: + description: Script import path + nullable: true + type: string + setPermissionsLinux: + description: Set permissions on Linux/macOS + nullable: true + type: boolean + skipFreeSpaceCheckWhenImporting: + description: Skip free space check when importing + nullable: true + type: boolean + sonarrInstanceRef: + description: Reference to the Sonarr instance + properties: + name: + default: '' + description: Name of the SonarrInstance resource + type: string + namespace: + description: Namespace of the SonarrInstance (optional, defaults to same namespace) + nullable: true + type: string + type: object + useScriptImport: + description: Use script for importing + nullable: true + type: boolean + required: + - sonarrInstanceRef + type: object + status: + nullable: true + properties: + conditions: + default: [] + description: Current conditions + items: + description: Condition contains details for one aspect of the current state of this API Resource. + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating details about the transition. This may be an empty string. + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance. + format: int64 + type: integer + reason: + description: reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty. + type: string + status: + description: status of the condition, one of True, False, Unknown. + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + observedGeneration: + default: 0 + description: Observed generation + format: int64 + type: integer + type: object + required: + - spec + title: SonarrMediaManagementConfig + type: object + served: true + storage: true + subresources: + status: {} +{{- end }} diff --git a/charts/sonarr-operator/templates/crds/metadata.yaml b/charts/sonarr-operator/templates/crds/metadata.yaml new file mode 100644 index 0000000..cc1ed2f --- /dev/null +++ b/charts/sonarr-operator/templates/crds/metadata.yaml @@ -0,0 +1,188 @@ +{{- if .Values.crds.install }} +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + {{- if or .Values.crds.keep .Values.crds.annotations }} + annotations: + {{- if .Values.crds.keep }} + helm.sh/resource-policy: keep + {{- end }} + {{- with .Values.crds.annotations }} +{{ toYaml . | indent 4 }} + {{- end }} + {{- end }} + {{- with .Values.crds.additionalLabels }} + labels: +{{ toYaml . | indent 4 }} + {{- end }} + name: sonarrmetadatas.devopsarr.io +spec: + group: devopsarr.io + names: + categories: [] + kind: SonarrMetadata + plural: sonarrmetadatas + shortNames: + - smeta + singular: sonarrmetadata + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.name + name: Name + type: string + - jsonPath: .spec.metadataType + name: Type + type: string + - jsonPath: .spec.enable + name: Enabled + type: boolean + - jsonPath: .status.id + name: ID + type: integer + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Auto-generated derived type for SonarrMetadataSpec via `CustomResource` + properties: + spec: + description: |- + SonarrMetadata represents a metadata consumer configuration in Sonarr + Metadata consumers write metadata files for media managers (Kodi, Plex, etc.) + properties: + config: + default: + episodeImages: false + episodeMetadata: false + seasonImages: false + seriesImages: false + seriesMetadata: false + seriesMetadataUrl: false + description: Metadata-specific configuration + properties: + episodeImages: + default: false + description: Write episode images (thumbnails) + type: boolean + episodeMetadata: + default: true + description: Write episode metadata (episode.nfo) + type: boolean + seasonImages: + default: true + description: Write season images + type: boolean + seriesImages: + default: true + description: Write series images (poster, banner, fanart) + type: boolean + seriesMetadata: + default: true + description: Write series metadata (series.nfo) + type: boolean + seriesMetadataUrl: + default: false + description: Write series metadata URL (deprecated) + type: boolean + type: object + enable: + default: true + description: Enable this metadata consumer + type: boolean + metadataType: + description: Metadata type/implementation + enum: + - xbmcMetadata + - roksboxMetadata + - wdtvMetadata + type: string + name: + description: Metadata consumer name + type: string + sonarrInstanceRef: + description: Reference to the SonarrInstance + properties: + name: + default: '' + description: Name of the SonarrInstance resource + type: string + namespace: + description: Namespace of the SonarrInstance (optional, defaults to same namespace) + nullable: true + type: string + type: object + tags: + default: [] + description: Tags for this metadata consumer + items: + format: int32 + type: integer + type: array + required: + - metadataType + - name + - sonarrInstanceRef + type: object + status: + nullable: true + properties: + conditions: + default: [] + description: Current conditions + items: + description: Condition contains details for one aspect of the current state of this API Resource. + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating details about the transition. This may be an empty string. + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance. + format: int64 + type: integer + reason: + description: reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty. + type: string + status: + description: status of the condition, one of True, False, Unknown. + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + id: + description: Sonarr Metadata ID + format: int32 + nullable: true + type: integer + observedGeneration: + default: 0 + description: Observed generation + format: int64 + type: integer + type: object + required: + - spec + title: SonarrMetadata + type: object + served: true + storage: true + subresources: + status: {} +{{- end }} diff --git a/charts/sonarr-operator/templates/crds/namingconfig.yaml b/charts/sonarr-operator/templates/crds/namingconfig.yaml new file mode 100644 index 0000000..9677bc6 --- /dev/null +++ b/charts/sonarr-operator/templates/crds/namingconfig.yaml @@ -0,0 +1,177 @@ +{{- if .Values.crds.install }} +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + {{- if or .Values.crds.keep .Values.crds.annotations }} + annotations: + {{- if .Values.crds.keep }} + helm.sh/resource-policy: keep + {{- end }} + {{- with .Values.crds.annotations }} +{{ toYaml . | indent 4 }} + {{- end }} + {{- end }} + {{- with .Values.crds.additionalLabels }} + labels: +{{ toYaml . | indent 4 }} + {{- end }} + name: sonarrnamingconfigs.devopsarr.io +spec: + group: devopsarr.io + names: + categories: [] + kind: SonarrNamingConfig + plural: sonarrnamingconfigs + shortNames: + - snc + singular: sonarrnamingconfig + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.sonarrInstanceRef.name + name: Instance + type: string + - jsonPath: .spec.renameEpisodes + name: Rename + type: boolean + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Auto-generated derived type for SonarrNamingConfigSpec via `CustomResource` + properties: + spec: + description: |- + SonarrNamingConfig configures episode naming settings for a Sonarr instance. + Only one SonarrNamingConfig per Sonarr instance is allowed. + properties: + animeEpisodeFormat: + description: |- + Anime episode format + Example: "{Series Title} - S{season:00}E{episode:00} - {Episode Title} {Quality Full}" + nullable: true + type: string + colonReplacementFormat: + description: Colon replacement format (0=Delete, 1=Dash, 2=SpaceDash, 3=SpaceDashSpace, 4=Smart) + format: int32 + nullable: true + type: integer + customColonReplacementFormat: + description: Custom colon replacement format string + nullable: true + type: string + dailyEpisodeFormat: + description: |- + Daily episode format + Example: "{Series Title} - {Air-Date} - {Episode Title} {Quality Full}" + nullable: true + type: string + multiEpisodeStyle: + description: Multi-episode style (0=Extend, 1=Duplicate, 2=Repeat, 3=Scene, 4=Range, 5=PrefixedRange) + format: int32 + nullable: true + type: integer + renameEpisodes: + description: Enable episode renaming + nullable: true + type: boolean + replaceIllegalCharacters: + description: Replace illegal characters in filenames + nullable: true + type: boolean + seasonFolderFormat: + description: |- + Season folder format + Example: "Season {season}" + nullable: true + type: string + seriesFolderFormat: + description: |- + Series folder format + Example: "{Series Title}" + nullable: true + type: string + sonarrInstanceRef: + description: Reference to the Sonarr instance + properties: + name: + default: '' + description: Name of the SonarrInstance resource + type: string + namespace: + description: Namespace of the SonarrInstance (optional, defaults to same namespace) + nullable: true + type: string + type: object + specialsFolderFormat: + description: |- + Specials folder format + Example: "Specials" + nullable: true + type: string + standardEpisodeFormat: + description: |- + Standard episode format + Example: "{Series Title} - S{season:00}E{episode:00} - {Episode Title} {Quality Full}" + nullable: true + type: string + required: + - sonarrInstanceRef + type: object + status: + nullable: true + properties: + conditions: + default: [] + description: Current conditions + items: + description: Condition contains details for one aspect of the current state of this API Resource. + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating details about the transition. This may be an empty string. + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance. + format: int64 + type: integer + reason: + description: reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty. + type: string + status: + description: status of the condition, one of True, False, Unknown. + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + observedGeneration: + default: 0 + description: Observed generation + format: int64 + type: integer + type: object + required: + - spec + title: SonarrNamingConfig + type: object + served: true + storage: true + subresources: + status: {} +{{- end }} diff --git a/charts/sonarr-operator/templates/crds/notification.yaml b/charts/sonarr-operator/templates/crds/notification.yaml new file mode 100644 index 0000000..63ad5bb --- /dev/null +++ b/charts/sonarr-operator/templates/crds/notification.yaml @@ -0,0 +1,477 @@ +{{- if .Values.crds.install }} +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + {{- if or .Values.crds.keep .Values.crds.annotations }} + annotations: + {{- if .Values.crds.keep }} + helm.sh/resource-policy: keep + {{- end }} + {{- with .Values.crds.annotations }} +{{ toYaml . | indent 4 }} + {{- end }} + {{- end }} + {{- with .Values.crds.additionalLabels }} + labels: +{{ toYaml . | indent 4 }} + {{- end }} + name: sonarrnotifications.devopsarr.io +spec: + group: devopsarr.io + names: + categories: [] + kind: SonarrNotification + plural: sonarrnotifications + shortNames: + - snot + singular: sonarrnotification + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.name + name: Name + type: string + - jsonPath: .spec.notificationType + name: Type + type: string + - jsonPath: .status.id + name: ID + type: integer + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Auto-generated derived type for SonarrNotificationSpec via `CustomResource` + properties: + spec: + description: |- + SonarrNotification represents a notification/connect configuration in Sonarr + Notifications are used to alert on events (Discord, Telegram, Webhook, etc.) + properties: + config: + description: Notification configuration + properties: + apiKeySecretRef: + description: API key secret reference + nullable: true + properties: + key: + description: Key in the secret + type: string + name: + description: Name of the secret + type: string + required: + - key + - name + type: object + appTokenSecretRef: + description: Gotify app token secret reference + nullable: true + properties: + key: + description: Key in the secret + type: string + name: + description: Name of the secret + type: string + required: + - key + - name + type: object + arguments: + description: Script arguments + nullable: true + type: string + authTokenSecretRef: + description: Auth token secret reference + nullable: true + properties: + key: + description: Key in the secret + type: string + name: + description: Name of the secret + type: string + required: + - key + - name + type: object + avatar: + description: Discord avatar + nullable: true + type: string + bcc: + default: [] + description: BCC addresses + items: + type: string + type: array + botTokenSecretRef: + description: Telegram bot token secret reference + nullable: true + properties: + key: + description: Key in the secret + type: string + name: + description: Name of the secret + type: string + required: + - key + - name + type: object + cc: + default: [] + description: CC addresses + items: + type: string + type: array + channel: + description: Slack channel + nullable: true + type: string + chatId: + description: Telegram chat ID + nullable: true + type: string + clickUrl: + description: Click URL + nullable: true + type: string + devices: + default: [] + description: Device list + items: + type: string + type: array + discordUsername: + description: Discord username + nullable: true + type: string + expire: + description: Expire after (seconds) + format: int32 + nullable: true + type: integer + from: + description: From address + nullable: true + type: string + host: + description: Server host + nullable: true + type: string + icon: + description: Slack icon + nullable: true + type: string + mapTo: + description: Notify on specific library sections + nullable: true + type: string + method: + description: HTTP Method (1 = POST, 2 = PUT) + format: int32 + nullable: true + type: integer + ntfyTags: + default: [] + description: Ntfy tags + items: + type: string + type: array + passwordSecretRef: + description: Password secret reference + nullable: true + properties: + key: + description: Key in the secret + type: string + name: + description: Name of the secret + type: string + required: + - key + - name + type: object + path: + description: Path to script + nullable: true + type: string + port: + description: SMTP port + format: int32 + nullable: true + type: integer + priority: + description: Priority level + format: int32 + nullable: true + type: integer + requireEncryption: + default: false + description: Require encryption + type: boolean + retry: + description: Retry interval (seconds) + format: int32 + nullable: true + type: integer + sendSilently: + default: false + description: Send silently + type: boolean + server: + description: SMTP server + nullable: true + type: string + serverUrl: + description: Ntfy server URL + nullable: true + type: string + slackWebhookUrl: + description: Slack webhook URL + nullable: true + type: string + sound: + description: Sound + nullable: true + type: string + to: + default: [] + description: To addresses + items: + type: string + type: array + topic: + description: Ntfy topic + nullable: true + type: string + updateLibrary: + default: false + description: Update library + type: boolean + url: + description: Webhook URL + nullable: true + type: string + useSsl: + default: false + description: Use SSL + type: boolean + userKeySecretRef: + description: User key secret reference + nullable: true + properties: + key: + description: Key in the secret + type: string + name: + description: Name of the secret + type: string + required: + - key + - name + type: object + username: + description: Username for basic auth + nullable: true + type: string + webhookUrl: + description: Discord webhook URL + nullable: true + type: string + type: object + name: + description: Notification name + type: string + notificationType: + description: Notification type + enum: + - Apprise + - CustomScript + - Discord + - Email + - Emby + - Gotify + - Join + - Kodi + - Mailgun + - Ntfy + - Plex + - Prowl + - Pushbullet + - Pushover + - SendGrid + - Signal + - Simplepush + - Slack + - SynologyIndexer + - Telegram + - Trakt + - Twitter + - Webhook + type: string + sonarrInstanceRef: + description: Reference to the SonarrInstance + properties: + name: + default: '' + description: Name of the SonarrInstance resource + type: string + namespace: + description: Namespace of the SonarrInstance (optional, defaults to same namespace) + nullable: true + type: string + type: object + tags: + default: [] + description: Tags for this notification + items: + format: int32 + type: integer + type: array + triggers: + default: + includeHealthWarnings: false + onApplicationUpdate: false + onDownload: false + onEpisodeFileDelete: false + onEpisodeFileDeleteForUpgrade: false + onGrab: false + onHealthIssue: false + onHealthRestored: false + onImportComplete: false + onManualInteractionRequired: false + onRename: false + onSeriesAdd: false + onSeriesDelete: false + onUpgrade: false + description: Event triggers + properties: + includeHealthWarnings: + default: false + description: Include health warnings + type: boolean + onApplicationUpdate: + default: false + description: On application update + type: boolean + onDownload: + default: false + description: On download (episode is downloaded) + type: boolean + onEpisodeFileDelete: + default: false + description: On episode file delete + type: boolean + onEpisodeFileDeleteForUpgrade: + default: false + description: On episode file delete for upgrade + type: boolean + onGrab: + default: false + description: On grab (episode is grabbed) + type: boolean + onHealthIssue: + default: false + description: On health issue + type: boolean + onHealthRestored: + default: false + description: On health restored + type: boolean + onImportComplete: + default: false + description: On import complete + type: boolean + onManualInteractionRequired: + default: false + description: On manual interaction required + type: boolean + onRename: + default: false + description: On rename + type: boolean + onSeriesAdd: + default: false + description: On series add + type: boolean + onSeriesDelete: + default: false + description: On series delete + type: boolean + onUpgrade: + default: false + description: On upgrade (episode is upgraded) + type: boolean + type: object + required: + - config + - name + - notificationType + - sonarrInstanceRef + type: object + status: + nullable: true + properties: + conditions: + default: [] + description: Current conditions + items: + description: Condition contains details for one aspect of the current state of this API Resource. + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating details about the transition. This may be an empty string. + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance. + format: int64 + type: integer + reason: + description: reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty. + type: string + status: + description: status of the condition, one of True, False, Unknown. + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + id: + description: Sonarr Notification ID + format: int32 + nullable: true + type: integer + observedGeneration: + default: 0 + description: Observed generation + format: int64 + type: integer + type: object + required: + - spec + title: SonarrNotification + type: object + served: true + storage: true + subresources: + status: {} +{{- end }} diff --git a/charts/sonarr-operator/templates/crds/qualitydefinition.yaml b/charts/sonarr-operator/templates/crds/qualitydefinition.yaml new file mode 100644 index 0000000..d6038f2 --- /dev/null +++ b/charts/sonarr-operator/templates/crds/qualitydefinition.yaml @@ -0,0 +1,178 @@ +{{- if .Values.crds.install }} +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + {{- if or .Values.crds.keep .Values.crds.annotations }} + annotations: + {{- if .Values.crds.keep }} + helm.sh/resource-policy: keep + {{- end }} + {{- with .Values.crds.annotations }} +{{ toYaml . | indent 4 }} + {{- end }} + {{- end }} + {{- with .Values.crds.additionalLabels }} + labels: +{{ toYaml . | indent 4 }} + {{- end }} + name: sonarrqualitydefinitions.devopsarr.io +spec: + group: devopsarr.io + names: + categories: [] + kind: SonarrQualityDefinition + plural: sonarrqualitydefinitions + shortNames: + - sqd + singular: sonarrqualitydefinition + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.qualityName + name: Quality + type: string + - jsonPath: .spec.title + name: Title + type: string + - jsonPath: .spec.minSize + name: MinSize + type: number + - jsonPath: .spec.maxSize + name: MaxSize + type: number + - jsonPath: .status.id + name: ID + type: integer + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Auto-generated derived type for SonarrQualityDefinitionSpec via `CustomResource` + properties: + spec: + description: |- + SonarrQualityDefinition represents a quality definition configuration in Sonarr + Quality definitions control the size limits for each quality level + properties: + maxSize: + description: Maximum size in MB per minute of runtime (None = unlimited) + format: double + nullable: true + type: number + minSize: + description: Minimum size in MB per minute of runtime + format: double + nullable: true + type: number + preferredSize: + description: Preferred size in MB per minute of runtime + format: double + nullable: true + type: number + qualityName: + description: Quality name (must match existing quality in Sonarr) + enum: + - UNKNOWN + - SDTV + - DVD + - WEBDL-480p + - WEBRip-480p + - Bluray-480p + - HDTV-720p + - HDTV-1080p + - Raw-HD + - WEBDL-720p + - WEBRip-720p + - Bluray-720p + - WEBDL-1080p + - WEBRip-1080p + - Bluray-1080p + - Bluray-1080p Remux + - HDTV-2160p + - WEBDL-2160p + - WEBRip-2160p + - Bluray-2160p + - Bluray-2160p Remux + type: string + sonarrInstanceRef: + description: Reference to the SonarrInstance + properties: + name: + default: '' + description: Name of the SonarrInstance resource + type: string + namespace: + description: Namespace of the SonarrInstance (optional, defaults to same namespace) + nullable: true + type: string + type: object + title: + description: Title/display name for this quality + nullable: true + type: string + required: + - qualityName + - sonarrInstanceRef + type: object + status: + nullable: true + properties: + conditions: + default: [] + description: Current conditions + items: + description: Condition contains details for one aspect of the current state of this API Resource. + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating details about the transition. This may be an empty string. + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance. + format: int64 + type: integer + reason: + description: reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty. + type: string + status: + description: status of the condition, one of True, False, Unknown. + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + id: + description: Sonarr Quality Definition ID + format: int32 + nullable: true + type: integer + observedGeneration: + default: 0 + description: Observed generation + format: int64 + type: integer + type: object + required: + - spec + title: SonarrQualityDefinition + type: object + served: true + storage: true + subresources: + status: {} +{{- end }} diff --git a/charts/sonarr-operator/templates/crds/qualityprofile.yaml b/charts/sonarr-operator/templates/crds/qualityprofile.yaml new file mode 100644 index 0000000..3ea33f7 --- /dev/null +++ b/charts/sonarr-operator/templates/crds/qualityprofile.yaml @@ -0,0 +1,218 @@ +{{- if .Values.crds.install }} +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + {{- if or .Values.crds.keep .Values.crds.annotations }} + annotations: + {{- if .Values.crds.keep }} + helm.sh/resource-policy: keep + {{- end }} + {{- with .Values.crds.annotations }} +{{ toYaml . | indent 4 }} + {{- end }} + {{- end }} + {{- with .Values.crds.additionalLabels }} + labels: +{{ toYaml . | indent 4 }} + {{- end }} + name: sonarrqualityprofiles.devopsarr.io +spec: + group: devopsarr.io + names: + categories: [] + kind: SonarrQualityProfile + plural: sonarrqualityprofiles + shortNames: + - sqp + singular: sonarrqualityprofile + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.name + name: Name + type: string + - jsonPath: .spec.cutoff + name: Cutoff + type: integer + - jsonPath: .status.id + name: ID + type: integer + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Auto-generated derived type for SonarrQualityProfileSpec via `CustomResource` + properties: + spec: + description: |- + SonarrQualityProfile represents a quality profile in Sonarr + Quality profiles define which qualities are acceptable and their priority + properties: + cutoff: + default: 0 + description: Quality ID to use as cutoff + format: int32 + type: integer + cutoffFormatScore: + description: Cutoff format score + format: int32 + nullable: true + type: integer + formatItems: + default: [] + description: Format items (custom formats with scores) + items: + properties: + format: + description: Custom format ID + format: int32 + nullable: true + type: integer + name: + description: Format name + nullable: true + type: string + score: + default: 0 + description: Score for this format + format: int32 + type: integer + type: object + type: array + minFormatScore: + description: Minimum format score + format: int32 + nullable: true + type: integer + minUpgradeFormatScore: + description: Minimum upgrade format score + format: int32 + nullable: true + type: integer + name: + description: Quality profile name + type: string + qualityGroups: + description: Ordered list of quality groups + items: + properties: + id: + description: Quality group ID + format: int32 + nullable: true + type: integer + name: + description: Quality group name + nullable: true + type: string + qualities: + description: Ordered list of qualities in this group + items: + properties: + id: + description: Quality ID + format: int32 + nullable: true + type: integer + name: + description: Quality name + nullable: true + type: string + resolution: + description: Resolution + format: int32 + nullable: true + type: integer + source: + description: Source type + nullable: true + type: string + type: object + type: array + required: + - qualities + type: object + type: array + sonarrInstanceRef: + description: Reference to the SonarrInstance + properties: + name: + default: '' + description: Name of the SonarrInstance resource + type: string + namespace: + description: Namespace of the SonarrInstance (optional, defaults to same namespace) + nullable: true + type: string + type: object + upgradeAllowed: + default: false + description: Whether upgrades are allowed + type: boolean + required: + - name + - qualityGroups + - sonarrInstanceRef + type: object + status: + nullable: true + properties: + conditions: + default: [] + description: Current conditions + items: + description: Condition contains details for one aspect of the current state of this API Resource. + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating details about the transition. This may be an empty string. + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance. + format: int64 + type: integer + reason: + description: reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty. + type: string + status: + description: status of the condition, one of True, False, Unknown. + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + id: + description: Sonarr Quality Profile ID + format: int32 + nullable: true + type: integer + observedGeneration: + default: 0 + description: Observed generation + format: int64 + type: integer + type: object + required: + - spec + title: SonarrQualityProfile + type: object + served: true + storage: true + subresources: + status: {} +{{- end }} diff --git a/charts/sonarr-operator/templates/crds/rootfolder.yaml b/charts/sonarr-operator/templates/crds/rootfolder.yaml new file mode 100644 index 0000000..7d59b1f --- /dev/null +++ b/charts/sonarr-operator/templates/crds/rootfolder.yaml @@ -0,0 +1,137 @@ +{{- if .Values.crds.install }} +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + {{- if or .Values.crds.keep .Values.crds.annotations }} + annotations: + {{- if .Values.crds.keep }} + helm.sh/resource-policy: keep + {{- end }} + {{- with .Values.crds.annotations }} +{{ toYaml . | indent 4 }} + {{- end }} + {{- end }} + {{- with .Values.crds.additionalLabels }} + labels: +{{ toYaml . | indent 4 }} + {{- end }} + name: sonarrrootfolders.devopsarr.io +spec: + group: devopsarr.io + names: + categories: [] + kind: SonarrRootFolder + plural: sonarrrootfolders + shortNames: + - srf + singular: sonarrrootfolder + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.path + name: Path + type: string + - jsonPath: .status.id + name: ID + type: integer + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Auto-generated derived type for SonarrRootFolderSpec via `CustomResource` + properties: + spec: + description: |- + SonarrRootFolder represents a root folder in Sonarr + Root folders are the base directories where series are stored + properties: + path: + description: Root folder absolute path + type: string + sonarrInstanceRef: + description: Reference to the SonarrInstance + properties: + name: + default: '' + description: Name of the SonarrInstance resource + type: string + namespace: + description: Namespace of the SonarrInstance (optional, defaults to same namespace) + nullable: true + type: string + type: object + required: + - path + - sonarrInstanceRef + type: object + status: + nullable: true + properties: + accessible: + description: Whether the folder is accessible + nullable: true + type: boolean + conditions: + default: [] + description: Current conditions + items: + description: Condition contains details for one aspect of the current state of this API Resource. + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating details about the transition. This may be an empty string. + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance. + format: int64 + type: integer + reason: + description: reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty. + type: string + status: + description: status of the condition, one of True, False, Unknown. + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + freeSpace: + description: Free space in the folder + format: int64 + nullable: true + type: integer + id: + description: Sonarr Root Folder ID + format: int32 + nullable: true + type: integer + observedGeneration: + default: 0 + description: Observed generation + format: int64 + type: integer + type: object + required: + - spec + title: SonarrRootFolder + type: object + served: true + storage: true + subresources: + status: {} +{{- end }} diff --git a/charts/sonarr-operator/templates/crds/series.yaml b/charts/sonarr-operator/templates/crds/series.yaml new file mode 100644 index 0000000..96096db --- /dev/null +++ b/charts/sonarr-operator/templates/crds/series.yaml @@ -0,0 +1,253 @@ +{{- if .Values.crds.install }} +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + {{- if or .Values.crds.keep .Values.crds.annotations }} + annotations: + {{- if .Values.crds.keep }} + helm.sh/resource-policy: keep + {{- end }} + {{- with .Values.crds.annotations }} +{{ toYaml . | indent 4 }} + {{- end }} + {{- end }} + {{- with .Values.crds.additionalLabels }} + labels: +{{ toYaml . | indent 4 }} + {{- end }} + name: sonarrseries.devopsarr.io +spec: + group: devopsarr.io + names: + categories: [] + kind: SonarrSeries + plural: sonarrseries + shortNames: + - ss + singular: sonarrseries + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.title + name: Title + type: string + - jsonPath: .spec.tvdbId + name: TVDB ID + type: integer + - jsonPath: .spec.monitored + name: Monitored + type: boolean + - jsonPath: .status.id + name: ID + type: integer + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Auto-generated derived type for SonarrSeriesSpec via `CustomResource` + properties: + spec: + description: |- + SonarrSeries represents a TV series managed in Sonarr + This allows declarative management of series in your library + properties: + addOptions: + default: + monitor: all + searchForCutoffUnmetEpisodes: false + searchForMissingEpisodes: true + description: Monitor type for adding series + properties: + monitor: + default: all + description: Monitor type + enum: + - all + - future + - missing + - existing + - recent + - pilot + - firstseason + - lastseason + - none + type: string + searchForCutoffUnmetEpisodes: + default: false + description: Search for cutoff unmet episodes + type: boolean + searchForMissingEpisodes: + default: true + description: Search for missing episodes when adding + type: boolean + type: object + monitored: + default: true + description: Whether the series is monitored + type: boolean + path: + description: Specific path override (optional) + nullable: true + type: string + qualityProfile: + description: Quality profile ID or name reference + properties: + id: + description: Quality profile ID + format: int32 + nullable: true + type: integer + name: + description: Quality profile name (will be resolved to ID) + nullable: true + type: string + type: object + rootFolderPath: + description: Root folder path for the series + type: string + seasonFolder: + default: true + description: Use season folders + type: boolean + seriesType: + default: standard + description: Series type + enum: + - standard + - daily + - anime + type: string + sonarrInstanceRef: + description: Reference to the SonarrInstance + properties: + name: + default: '' + description: Name of the SonarrInstance resource + type: string + namespace: + description: Namespace of the SonarrInstance (optional, defaults to same namespace) + nullable: true + type: string + type: object + tags: + default: [] + description: Tags for this series + items: + format: int32 + type: integer + type: array + title: + description: Series title + type: string + titleSlug: + description: Title slug (kebab-case version of title) + type: string + tvdbId: + description: TVDB ID for the series + format: int32 + type: integer + useSceneNumbering: + default: false + description: Use scene numbering + type: boolean + required: + - qualityProfile + - rootFolderPath + - sonarrInstanceRef + - title + - titleSlug + - tvdbId + type: object + status: + nullable: true + properties: + conditions: + default: [] + description: Current conditions + items: + description: Condition contains details for one aspect of the current state of this API Resource. + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating details about the transition. This may be an empty string. + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance. + format: int64 + type: integer + reason: + description: reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty. + type: string + status: + description: status of the condition, one of True, False, Unknown. + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + episodeCount: + description: Total episode count + format: int32 + nullable: true + type: integer + episodeFileCount: + description: Episode file count + format: int32 + nullable: true + type: integer + id: + description: Sonarr Series ID + format: int32 + nullable: true + type: integer + network: + description: Network + nullable: true + type: string + nextAiring: + description: Next airing date + nullable: true + type: string + observedGeneration: + default: 0 + description: Observed generation + format: int64 + type: integer + percentComplete: + description: Percentage complete + format: double + nullable: true + type: number + previousAiring: + description: Previous airing date + nullable: true + type: string + seriesStatus: + description: Status (continuing, ended, etc.) + nullable: true + type: string + type: object + required: + - spec + title: SonarrSeries + type: object + served: true + storage: true + subresources: + status: {} +{{- end }} diff --git a/charts/sonarr-operator/templates/crds/sonarr.yaml b/charts/sonarr-operator/templates/crds/sonarr.yaml new file mode 100644 index 0000000..ab595d8 --- /dev/null +++ b/charts/sonarr-operator/templates/crds/sonarr.yaml @@ -0,0 +1,655 @@ +{{- if .Values.crds.install }} +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + {{- if or .Values.crds.keep .Values.crds.annotations }} + annotations: + {{- if .Values.crds.keep }} + helm.sh/resource-policy: keep + {{- end }} + {{- with .Values.crds.annotations }} +{{ toYaml . | indent 4 }} + {{- end }} + {{- end }} + {{- with .Values.crds.additionalLabels }} + labels: +{{ toYaml . | indent 4 }} + {{- end }} + name: sonarrs.devopsarr.io +spec: + group: devopsarr.io + names: + categories: [] + kind: Sonarr + plural: sonarrs + shortNames: + - snr + singular: sonarr + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .status.url + name: URL + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Auto-generated derived type for SonarrSpec via `CustomResource` + properties: + spec: + description: |- + Sonarr is the main CRD that deploys and manages a Sonarr instance + + This CRD creates: + - A Deployment with the Sonarr container + - An init container for database migrations + - A Service to expose Sonarr + - A PersistentVolumeClaim for configuration storage + - Optional Ingress for external access + properties: + apiKeySecretRef: + description: API key secret reference (optional - will be auto-generated if not provided) + nullable: true + properties: + key: + description: Key in the secret + type: string + name: + description: Name of the secret + type: string + required: + - key + - name + type: object + config: + default: + analyticsEnabled: null + authenticationMethod: null + authenticationRequired: null + bindAddress: null + initContainerImage: null + instanceName: null + logLevel: null + urlBase: null + description: Sonarr application configuration (config.xml settings) + properties: + analyticsEnabled: + description: 'Analytics enabled (default: true)' + nullable: true + type: boolean + authenticationMethod: + description: 'Authentication method: None, Basic, Forms, External (default: None)' + nullable: true + type: string + authenticationRequired: + description: 'Authentication required for API access (default: false)' + nullable: true + type: boolean + bindAddress: + description: 'Bind address (default: "*")' + nullable: true + type: string + initContainerImage: + description: 'Init container image used to configure config.xml (default: busybox:latest)' + nullable: true + type: string + instanceName: + description: Instance name displayed in the UI + nullable: true + type: string + logLevel: + description: 'Log level: trace, debug, info, warn, error (default: info)' + nullable: true + type: string + urlBase: + description: URL base for reverse proxy setups (e.g., "/sonarr") + nullable: true + type: string + type: object + env: + default: [] + description: Environment variables + items: + properties: + name: + description: Name of the environment variable + type: string + value: + description: Value of the environment variable + nullable: true + type: string + valueFrom: + description: Reference to a secret or configmap + nullable: true + properties: + configMapKeyRef: + description: ConfigMap key reference + nullable: true + properties: + key: + description: Key in the configmap + type: string + name: + description: Name of the configmap + type: string + required: + - key + - name + type: object + secretKeyRef: + description: Secret key reference + nullable: true + properties: + key: + description: Key in the secret + type: string + name: + description: Name of the secret + type: string + required: + - key + - name + type: object + type: object + required: + - name + type: object + type: array + httpRoute: + description: HTTPRoute configuration for Gateway API (optional) + nullable: true + properties: + annotations: + additionalProperties: + type: string + default: {} + description: Additional annotations for the HTTPRoute + type: object + enabled: + default: false + description: 'Enable HTTPRoute creation (default: false)' + type: boolean + gatewayRef: + description: Gateway reference - the Gateway to attach to + properties: + name: + description: Name of the Gateway + type: string + namespace: + description: Namespace of the Gateway (optional, defaults to same namespace as HTTPRoute) + nullable: true + type: string + sectionName: + description: Section name within the Gateway (optional) + nullable: true + type: string + required: + - name + type: object + hostnames: + default: [] + description: Hostnames for the HTTPRoute + items: + type: string + type: array + labels: + additionalProperties: + type: string + default: {} + description: Additional labels for the HTTPRoute + type: object + path: + default: / + description: 'Path match for the route (default: /)' + type: string + pathType: + default: PathPrefix + description: 'Path match type: Exact, PathPrefix, or RegularExpression (default: PathPrefix)' + type: string + required: + - gatewayRef + type: object + image: + default: lscr.io/linuxserver/sonarr:latest + description: 'Sonarr image to use (default: lscr.io/linuxserver/sonarr:latest)' + type: string + imagePullPolicy: + default: IfNotPresent + description: 'Image pull policy (default: IfNotPresent)' + type: string + ingress: + description: Ingress configuration (optional) + nullable: true + properties: + annotations: + additionalProperties: + type: string + default: {} + description: Ingress annotations + type: object + enabled: + default: false + description: 'Enable ingress (default: false)' + type: boolean + host: + description: Hostname for the ingress + type: string + ingressClassName: + description: Ingress class name + nullable: true + type: string + path: + default: / + description: 'Path for the ingress (default: /)' + type: string + pathType: + default: Prefix + description: 'Path type (default: Prefix)' + type: string + tls: + description: TLS configuration + nullable: true + properties: + hosts: + default: [] + description: Hosts covered by the TLS certificate + items: + type: string + type: array + secretName: + description: Secret name containing TLS certificate + type: string + required: + - secretName + type: object + required: + - host + type: object + initContainer: + description: Init container configuration (for custom init logic) + nullable: true + properties: + args: + default: [] + description: Arguments for the command + items: + type: string + type: array + command: + default: [] + description: Command to run in init container + items: + type: string + type: array + env: + default: [] + description: Environment variables for init container + items: + properties: + name: + description: Name of the environment variable + type: string + value: + description: Value of the environment variable + nullable: true + type: string + valueFrom: + description: Reference to a secret or configmap + nullable: true + properties: + configMapKeyRef: + description: ConfigMap key reference + nullable: true + properties: + key: + description: Key in the configmap + type: string + name: + description: Name of the configmap + type: string + required: + - key + - name + type: object + secretKeyRef: + description: Secret key reference + nullable: true + properties: + key: + description: Key in the secret + type: string + name: + description: Name of the secret + type: string + required: + - key + - name + type: object + type: object + required: + - name + type: object + type: array + image: + default: busybox:latest + description: 'Image for init container (default: busybox:latest)' + type: string + type: object + nodeSelector: + additionalProperties: + type: string + default: {} + description: Node selector + type: object + replicas: + default: 1 + description: Number of replicas (should be 1 for Sonarr) + format: int32 + type: integer + resources: + description: Resource requirements + nullable: true + properties: + limits: + additionalProperties: + type: string + default: {} + description: Resource limits + type: object + requests: + additionalProperties: + type: string + default: {} + description: Resource requests + type: object + type: object + securityContext: + description: Pod security context + nullable: true + properties: + fsGroup: + format: int64 + nullable: true + type: integer + runAsGroup: + format: int64 + nullable: true + type: integer + runAsNonRoot: + nullable: true + type: boolean + runAsUser: + format: int64 + nullable: true + type: integer + type: object + service: + default: + annotations: {} + containerPort: 0 + nodePort: null + port: 0 + serviceType: '' + description: Service configuration + properties: + annotations: + additionalProperties: + type: string + default: {} + description: Service annotations + type: object + containerPort: + default: 8989 + description: 'Container port - the port Sonarr listens on inside the container (default: 8989)' + format: int32 + type: integer + nodePort: + description: Node port (only for NodePort type) + format: int32 + nullable: true + type: integer + port: + default: 8989 + description: 'Service port (default: 8989)' + format: int32 + type: integer + serviceType: + default: ClusterIP + description: 'Service type (default: ClusterIP)' + type: string + type: object + storage: + default: + accessModes: [] + existingClaim: null + size: '' + storageClass: null + description: Storage configuration + properties: + accessModes: + default: + - ReadWriteOnce + description: 'Access modes (default: ReadWriteOnce)' + items: + type: string + type: array + existingClaim: + description: Existing PVC to use (optional) + nullable: true + type: string + size: + default: 1Gi + description: 'Size of the config PVC (default: 1Gi)' + type: string + storageClass: + description: Storage class for the PVC + nullable: true + type: string + type: object + tolerations: + default: [] + description: Tolerations + items: + properties: + effect: + nullable: true + type: string + key: + nullable: true + type: string + operator: + nullable: true + type: string + tolerationSeconds: + format: int64 + nullable: true + type: integer + value: + nullable: true + type: string + type: object + type: array + volumeMounts: + default: [] + description: Volume mounts for media directories + items: + properties: + mountPath: + description: Mount path inside the container + type: string + name: + description: Name of the volume + type: string + readOnly: + default: false + description: Read only flag + type: boolean + subPath: + description: Sub path (optional) + nullable: true + type: string + required: + - mountPath + - name + type: object + type: array + volumes: + default: [] + description: Additional volumes + items: + properties: + configMap: + description: ConfigMap volume + nullable: true + properties: + items: + default: [] + items: + properties: + key: + type: string + path: + type: string + required: + - key + - path + type: object + type: array + name: + type: string + required: + - name + type: object + emptyDir: + description: Empty dir volume + nullable: true + properties: + medium: + nullable: true + type: string + sizeLimit: + nullable: true + type: string + type: object + hostPath: + description: HostPath volume + nullable: true + properties: + path: + type: string + type: + nullable: true + type: string + required: + - path + type: object + name: + description: Name of the volume + type: string + nfs: + description: NFS volume + nullable: true + properties: + path: + type: string + readOnly: + default: false + type: boolean + server: + type: string + required: + - path + - server + type: object + persistentVolumeClaim: + description: PVC claim + nullable: true + properties: + claimName: + type: string + readOnly: + default: false + type: boolean + required: + - claimName + type: object + required: + - name + type: object + type: array + type: object + status: + nullable: true + properties: + apiKeySecret: + description: API key (stored in secret) + nullable: true + type: string + conditions: + default: [] + description: Current conditions + items: + description: Condition contains details for one aspect of the current state of this API Resource. + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating details about the transition. This may be an empty string. + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance. + format: int64 + type: integer + reason: + description: reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty. + type: string + status: + description: status of the condition, one of True, False, Unknown. + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + observedGeneration: + default: 0 + description: Observed generation + format: int64 + type: integer + readyReplicas: + default: 0 + description: Number of ready replicas + format: int32 + type: integer + url: + description: URL to access Sonarr + nullable: true + type: string + version: + description: Sonarr version + nullable: true + type: string + type: object + required: + - spec + title: Sonarr + type: object + served: true + storage: true + subresources: + status: {} +{{- end }} diff --git a/charts/sonarr-operator/templates/crds/tag.yaml b/charts/sonarr-operator/templates/crds/tag.yaml new file mode 100644 index 0000000..a601a4f --- /dev/null +++ b/charts/sonarr-operator/templates/crds/tag.yaml @@ -0,0 +1,128 @@ +{{- if .Values.crds.install }} +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + {{- if or .Values.crds.keep .Values.crds.annotations }} + annotations: + {{- if .Values.crds.keep }} + helm.sh/resource-policy: keep + {{- end }} + {{- with .Values.crds.annotations }} +{{ toYaml . | indent 4 }} + {{- end }} + {{- end }} + {{- with .Values.crds.additionalLabels }} + labels: +{{ toYaml . | indent 4 }} + {{- end }} + name: sonarrtags.devopsarr.io +spec: + group: devopsarr.io + names: + categories: [] + kind: SonarrTag + plural: sonarrtags + shortNames: + - stag + singular: sonarrtag + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.label + name: Label + type: string + - jsonPath: .status.id + name: ID + type: integer + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Auto-generated derived type for SonarrTagSpec via `CustomResource` + properties: + spec: + description: |- + SonarrTag represents a tag in Sonarr + Tags are used to organize and filter series, profiles, and other resources + properties: + label: + description: Tag label (must be lowercase) + type: string + sonarrInstanceRef: + description: Reference to the SonarrInstance + properties: + name: + default: '' + description: Name of the SonarrInstance resource + type: string + namespace: + description: Namespace of the SonarrInstance (optional, defaults to same namespace) + nullable: true + type: string + type: object + required: + - label + - sonarrInstanceRef + type: object + status: + nullable: true + properties: + conditions: + default: [] + description: Current conditions + items: + description: Condition contains details for one aspect of the current state of this API Resource. + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating details about the transition. This may be an empty string. + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance. + format: int64 + type: integer + reason: + description: reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty. + type: string + status: + description: status of the condition, one of True, False, Unknown. + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + id: + description: Sonarr Tag ID + format: int32 + nullable: true + type: integer + observedGeneration: + default: 0 + description: Observed generation + format: int64 + type: integer + type: object + required: + - spec + title: SonarrTag + type: object + served: true + storage: true + subresources: + status: {} +{{- end }} diff --git a/charts/sonarr-operator/templates/deployment.yaml b/charts/sonarr-operator/templates/deployment.yaml new file mode 100644 index 0000000..2fe51ac --- /dev/null +++ b/charts/sonarr-operator/templates/deployment.yaml @@ -0,0 +1,74 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "sonarr-operator.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "sonarr-operator.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + {{- include "sonarr-operator.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "sonarr-operator.selectorLabels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + serviceAccountName: {{ include "sonarr-operator.serviceAccountName" . }} + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.priorityClassName }} + priorityClassName: {{ . }} + {{- end }} + {{- with .Values.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: operator + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + env: + - name: RUST_LOG + value: {{ .Values.logLevel | quote }} + {{- with .Values.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.livenessProbe }} + livenessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.readinessProbe }} + readinessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/charts/sonarr-operator/templates/serviceaccount.yaml b/charts/sonarr-operator/templates/serviceaccount.yaml new file mode 100644 index 0000000..ac235ca --- /dev/null +++ b/charts/sonarr-operator/templates/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "sonarr-operator.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "sonarr-operator.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/charts/sonarr-operator/values.yaml b/charts/sonarr-operator/values.yaml new file mode 100644 index 0000000..3f5ee9f --- /dev/null +++ b/charts/sonarr-operator/values.yaml @@ -0,0 +1,97 @@ +# Default values for sonarr-operator. + +# -- Number of operator replicas. Should remain at 1 (the operator does not yet support leader election). +replicaCount: 1 + +image: + # -- Container image repository. + repository: ghcr.io/devopsarr/k8s-operator-sonarr + # -- Image pull policy. + pullPolicy: IfNotPresent + # -- Image tag. Defaults to the chart's appVersion when empty. + tag: "" + +# -- Image pull secrets for private registries. +imagePullSecrets: [] + +# -- Override the chart name used in resource names. +nameOverride: "" +# -- Fully override the generated fullname used in resource names. +fullnameOverride: "" + +# CRD-related options. +# Disable when managing CRDs out-of-band (e.g. Flux Kustomization, ArgoCD +# ServerSideApply, or multi-tenant clusters where CRDs are installed once). +crds: + # -- Install CRDs via Helm templates when true. + install: true + # -- When true, adds `helm.sh/resource-policy: keep` to each CRD so they + # survive `helm uninstall`. Set to false for ephemeral test clusters. + keep: true + # -- Additional annotations merged into each CRD's `metadata.annotations`. + annotations: {} + # -- Additional labels merged into each CRD's `metadata.labels`. + additionalLabels: {} + +serviceAccount: + # -- Create a ServiceAccount for the operator. + create: true + # -- Annotations to add to the ServiceAccount. + annotations: {} + # -- Name of the ServiceAccount. Auto-generated when empty. + name: "" + +rbac: + # -- Create ClusterRole and ClusterRoleBinding for the operator. + create: true + +# -- Operator log level. Format: `,=` (passed as RUST_LOG). +logLevel: "info,sonarr_operator=debug" + +# -- Extra environment variables for the operator container. +extraEnv: [] +# - name: FOO +# value: bar + +# -- Pod-level security context. +podSecurityContext: + fsGroup: 65534 + +# -- Container-level security context. +securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsUser: 65534 + capabilities: + drop: + - ALL + +# -- Resource requests and limits for the operator container. +resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 200m + memory: 256Mi + +# -- Pod annotations. +podAnnotations: {} +# -- Pod labels. +podLabels: {} + +# -- Node selector for the operator Pod. +nodeSelector: {} +# -- Tolerations for the operator Pod. +tolerations: [] +# -- Affinity rules for the operator Pod. +affinity: {} + +# -- Priority class name for the operator Pod. +priorityClassName: "" + +# -- Liveness probe for the operator container. +livenessProbe: {} +# -- Readiness probe for the operator container. +readinessProbe: {} diff --git a/deploy/deployment.yaml b/deploy/deployment.yaml new file mode 100644 index 0000000..fb72f9f --- /dev/null +++ b/deploy/deployment.yaml @@ -0,0 +1,40 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: sonarr-operator + namespace: sonarr-operator-system + labels: + app.kubernetes.io/name: sonarr-operator + app.kubernetes.io/component: operator +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: sonarr-operator + template: + metadata: + labels: + app.kubernetes.io/name: sonarr-operator + spec: + serviceAccountName: sonarr-operator + containers: + - name: operator + image: ghcr.io/devopsarr/k8s-operator-sonarr:v0.1.0 # x-release-please-version + imagePullPolicy: IfNotPresent + env: + - name: RUST_LOG + value: 'info,sonarr_operator=debug' + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 200m + memory: 256Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsUser: 65534 + securityContext: + fsGroup: 65534 diff --git a/deploy/examples/sonarr-minimal.yaml b/deploy/examples/sonarr-minimal.yaml new file mode 100644 index 0000000..2175d93 --- /dev/null +++ b/deploy/examples/sonarr-minimal.yaml @@ -0,0 +1,40 @@ +# Minimal Sonarr instance. +# +# This example creates a single Sonarr deployment with default settings. +# The operator will reconcile this resource into a Deployment, Service, +# PVC, and (if configured) Ingress/HTTPRoute. +# +# Prerequisites: +# 1. The sonarr-operator chart must be installed in the cluster. +# 2. Either omit `apiKeySecretRef` (the operator will auto-generate a Secret) +# OR pre-create a Secret with your chosen key: +# +# kubectl create secret generic sonarr-api-key \ +# --from-literal=api-key="$(openssl rand -hex 16)" \ +# -n default +# +# Apply: +# kubectl apply -f sonarr-minimal.yaml +# kubectl wait sonarr/sonarr --for=condition=Ready --timeout=5m +--- +apiVersion: devopsarr.io/v1alpha1 +kind: Sonarr +metadata: + name: sonarr + namespace: default +spec: + # Reference to a Secret containing the API key. Omit to have the + # operator auto-generate one. + apiKeySecretRef: + name: sonarr-api-key + key: api-key + + # Expose Sonarr via a ClusterIP Service on the default port (8989). + service: + serviceType: ClusterIP + + # Application configuration written into config.xml on first start. + config: + # Use Forms authentication in production; None is shown here for + # the simplest possible quickstart. + authenticationMethod: None diff --git a/deploy/namespace.yaml b/deploy/namespace.yaml new file mode 100644 index 0000000..463f28e --- /dev/null +++ b/deploy/namespace.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: sonarr-operator-system + labels: + app.kubernetes.io/name: sonarr-operator + app.kubernetes.io/managed-by: sonarr-operator diff --git a/deploy/rbac.yaml b/deploy/rbac.yaml new file mode 100644 index 0000000..a27e181 --- /dev/null +++ b/deploy/rbac.yaml @@ -0,0 +1,59 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: sonarr-operator + namespace: sonarr-operator-system +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: sonarr-operator +rules: + # Core API resources + - apiGroups: [''] + resources: ['secrets'] + verbs: ['get', 'list', 'watch', 'create', 'update', 'patch', 'delete'] + - apiGroups: [''] + resources: ['services'] + verbs: ['get', 'list', 'watch', 'create', 'update', 'patch', 'delete'] + - apiGroups: [''] + resources: ['persistentvolumeclaims'] + verbs: ['get', 'list', 'watch', 'create', 'update', 'patch', 'delete'] + - apiGroups: [''] + resources: ['events'] + verbs: ['create', 'patch'] + # Apps API + - apiGroups: ['apps'] + resources: ['deployments'] + verbs: ['get', 'list', 'watch', 'create', 'update', 'patch', 'delete'] + # Networking API (Ingress) + - apiGroups: ['networking.k8s.io'] + resources: ['ingresses'] + verbs: ['get', 'list', 'watch', 'create', 'update', 'patch', 'delete'] + # Gateway API (HTTPRoute) + - apiGroups: ['gateway.networking.k8s.io'] + resources: ['httproutes'] + verbs: ['get', 'list', 'watch', 'create', 'update', 'patch', 'delete'] + # Sonarr CRDs + - apiGroups: ['devopsarr.io'] + resources: ['*'] + verbs: ['*'] + - apiGroups: ['devopsarr.io'] + resources: ['*/status'] + verbs: ['get', 'patch', 'update'] + - apiGroups: ['devopsarr.io'] + resources: ['*/finalizers'] + verbs: ['update'] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: sonarr-operator +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: sonarr-operator +subjects: + - kind: ServiceAccount + name: sonarr-operator + namespace: sonarr-operator-system diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..a6baaf0 --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,144 @@ +# Testing the Sonarr Kubernetes Operator + +This guide covers the different testing approaches, from unit tests to full end-to-end testing on Kubernetes. + +## Prerequisites + +- Rust toolchain (1.88+) +- Docker +- kubectl +- [k3d](https://k3d.io/) for local Kubernetes clusters + +## Quick Start + +```bash +# Lint and unit tests +make lint test + +# Build +make build + +# Generate CRDs +make crds +``` + +--- + +## 1. Unit Testing + +```bash +make test +``` + +Unit tests are located in each module and test individual functions without external dependencies. + +--- + +## 2. Integration Testing + +Integration tests verify CRD schemas and validation against a live Kubernetes cluster (no Sonarr instance required). + +```bash +# Create a k3d cluster and install CRDs +make e2e-cluster-create +make install + +# Run integration tests +make integration-test + +# Cleanup +make e2e-cluster-delete +``` + +--- + +## 3. End-to-End Testing + +E2E tests verify the full reconciliation loop: the operator watches CRDs, calls the Sonarr API, and updates resource status. + +### Architecture + +1. Create a k3d cluster with NodePort mapping and install CRDs +2. Run the operator locally (or as a Deployment) +3. Create an API key Secret and apply the **Sonarr CR** +4. The operator reconciles the Sonarr CR → creates Deployment, Service, PVC +5. Sonarr boots with the deterministic API key +6. Tests create sub-resource CRs (Tags, RootFolders, etc.) and verify via the Sonarr API + +### Local Setup + +```bash +# Terminal 1: Create cluster + deploy Sonarr +make e2e-up + +# Terminal 2: Run the operator locally +make run-debug + +# Terminal 3: Run E2E tests +make e2e + +# Verbose with debug logging +make e2e-verbose +``` + +### Fixture Files + +The only fixture file is: + +- **`tests/e2e/fixtures/sonarr-instance.yaml`** — the Sonarr CR that the operator reconciles into a running Sonarr instance with `serviceType: NodePort` on port 30989. + +All test resources (Tags, RootFolders, QualityProfiles, etc.) are created programmatically by the Rust test code. + +### Cleanup + +```bash +# Remove test resources (keep cluster) +make e2e-cleanup + +# Tear down everything +make e2e-down +``` + +--- + +## 4. CI Pipeline + +The GitHub Actions CI pipeline (`.github/workflows/ci.yml`) automates the full test suite: + +1. **Lint & Format** — `cargo fmt --check` + `cargo clippy` +2. **Unit Tests** — `cargo test --lib` +3. **Generate CRDs** — `cargo run --bin crdgen` +4. **Build & Push Image** — Docker build + push to GHCR +5. **Integration Tests** — CRD schema validation on K8s v1.28, v1.29, and latest +6. **E2E Tests** — Full reconciliation against a live Sonarr instance in k3d + +--- + +## 5. Debugging Tips + +### Check Operator Logs + +```bash +# Local +RUST_LOG=debug cargo run --bin sonarr-operator + +# In-cluster +kubectl logs -n sonarr-operator-system deployment/sonarr-operator -f +``` + +### Verify Sonarr API + +```bash +curl -H "X-Api-Key: $SONARR_API_KEY" http://localhost:8989/api/v3/tag +curl -H "X-Api-Key: $SONARR_API_KEY" http://localhost:8989/api/v3/system/status +``` + +### Common Issues + +| Symptom | Fix | +|---------|-----| +| `SONARR_API_KEY not set` | `export SONARR_API_KEY=` | +| Sonarr CR never becomes Ready | Check operator logs: `kubectl logs -n sonarr-operator-system deploy/sonarr-operator` | +| CRD not found | Run `make install` to apply CRDs | +| Finalizer stuck on delete | Check operator logs for cleanup errors | +| NodePort not reachable | Ensure k3d was created with `--port 8989:30989@server:0` | diff --git a/docs/api/crd-reference.md b/docs/api/crd-reference.md new file mode 100644 index 0000000..285520c --- /dev/null +++ b/docs/api/crd-reference.md @@ -0,0 +1,8806 @@ +# API Reference + +Packages: + +- [devopsarr.io/v1alpha1](#devopsarriov1alpha1) + +# devopsarr.io/v1alpha1 + +Resource Types: + +- [SonarrAutoTag](#sonarrautotag) + +- [SonarrCustomFormat](#sonarrcustomformat) + +- [SonarrDelayProfile](#sonarrdelayprofile) + +- [SonarrDownloadClient](#sonarrdownloadclient) + +- [SonarrDownloadClientConfig](#sonarrdownloadclientconfig) + +- [SonarrImportList](#sonarrimportlist) + +- [SonarrIndexer](#sonarrindexer) + +- [SonarrIndexerConfig](#sonarrindexerconfig) + +- [SonarrLanguageProfile](#sonarrlanguageprofile) + +- [SonarrMediaManagementConfig](#sonarrmediamanagementconfig) + +- [SonarrMetadata](#sonarrmetadata) + +- [SonarrNamingConfig](#sonarrnamingconfig) + +- [SonarrNotification](#sonarrnotification) + +- [SonarrQualityDefinition](#sonarrqualitydefinition) + +- [SonarrQualityProfile](#sonarrqualityprofile) + +- [SonarrRootFolder](#sonarrrootfolder) + +- [SonarrSeries](#sonarrseries) + +- [Sonarr](#sonarr) + +- [SonarrTag](#sonarrtag) + + + + +## SonarrAutoTag +[↩ Parent](#devopsarriov1alpha1 ) + + + + + + +Auto-generated derived type for SonarrAutoTagSpec via `CustomResource` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
apiVersionstringdevopsarr.io/v1alpha1true
kindstringSonarrAutoTagtrue
metadataobjectRefer to the Kubernetes API documentation for the fields of the `metadata` field.true
specobject + SonarrAutoTag represents an auto-tagging rule configuration in Sonarr +Auto-tagging automatically applies tags to series based on conditions
+
true
statusobject +
+
false
+ + +### SonarrAutoTag.spec +[↩ Parent](#sonarrautotag) + + + +SonarrAutoTag represents an auto-tagging rule configuration in Sonarr +Auto-tagging automatically applies tags to series based on conditions + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring + Auto-tag rule name
+
true
sonarrInstanceRefobject + Reference to the SonarrInstance
+
true
removeTagsAutomaticallyboolean + Remove tags automatically when conditions no longer match
+
+ Default: false
+
false
specifications[]object + Specifications (conditions) for this auto-tag rule
+
+ Default: []
+
false
tags[]integer + Tags to apply when conditions match
+
+ Default: []
+
false
+ + +### SonarrAutoTag.spec.sonarrInstanceRef +[↩ Parent](#sonarrautotagspec) + + + +Reference to the SonarrInstance + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring + Name of the SonarrInstance resource
+
+ Default:
+
false
namespacestring + Namespace of the SonarrInstance (optional, defaults to same namespace)
+
false
+ + +### SonarrAutoTag.spec.specifications[index] +[↩ Parent](#sonarrautotagspec) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
implementationenum + Specification type/implementation
+
+ Enum: rootFolderSpecification, genreSpecification, yearSpecification, seriesTypeSpecification, qualityProfileSpecification, networkSpecification, originalLanguageSpecification, tagSpecification
+
true
namestring + Specification name
+
true
fieldsobject + Fields/values for this specification
+
+ Default: map[max: min: value:]
+
false
negateboolean + Negate this condition
+
+ Default: false
+
false
requiredboolean + This condition is required
+
+ Default: true
+
false
+ + +### SonarrAutoTag.spec.specifications[index].fields +[↩ Parent](#sonarrautotagspecspecificationsindex) + + + +Fields/values for this specification + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
maxinteger + Maximum value (for year specifications)
+
+ Format: int32
+
false
mininteger + Minimum value (for year specifications)
+
+ Format: int32
+
false
valuestring + Value for the specification (path, genre, network, etc.)
+
false
+ + +### SonarrAutoTag.status +[↩ Parent](#sonarrautotag) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
conditions[]object + Current conditions
+
+ Default: []
+
false
idinteger + Sonarr Auto Tag ID
+
+ Format: int32
+
false
observedGenerationinteger + Observed generation
+
+ Format: int64
+ Default: 0
+
false
+ + +### SonarrAutoTag.status.conditions[index] +[↩ Parent](#sonarrautotagstatus) + + + +Condition contains details for one aspect of the current state of this API Resource. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
lastTransitionTimestring + lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+
+ Format: date-time
+
true
messagestring + message is a human readable message indicating details about the transition. This may be an empty string.
+
true
reasonstring + reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty.
+
true
statusstring + status of the condition, one of True, False, Unknown.
+
true
typestring + type of condition in CamelCase or in foo.example.com/CamelCase.
+
true
observedGenerationinteger + observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance.
+
+ Format: int64
+
false
+ +## SonarrCustomFormat +[↩ Parent](#devopsarriov1alpha1 ) + + + + + + +Auto-generated derived type for SonarrCustomFormatSpec via `CustomResource` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
apiVersionstringdevopsarr.io/v1alpha1true
kindstringSonarrCustomFormattrue
metadataobjectRefer to the Kubernetes API documentation for the fields of the `metadata` field.true
specobject + SonarrCustomFormat represents a custom format configuration in Sonarr +Custom formats are used to score releases based on various criteria
+
true
statusobject +
+
false
+ + +### SonarrCustomFormat.spec +[↩ Parent](#sonarrcustomformat) + + + +SonarrCustomFormat represents a custom format configuration in Sonarr +Custom formats are used to score releases based on various criteria + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring + Custom format name
+
true
sonarrInstanceRefobject + Reference to the SonarrInstance
+
true
includeCustomFormatWhenRenamingboolean + Include custom format name when renaming files
+
+ Default: false
+
false
specifications[]object + Specifications (conditions) for this custom format
+
+ Default: []
+
false
+ + +### SonarrCustomFormat.spec.sonarrInstanceRef +[↩ Parent](#sonarrcustomformatspec) + + + +Reference to the SonarrInstance + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring + Name of the SonarrInstance resource
+
+ Default:
+
false
namespacestring + Namespace of the SonarrInstance (optional, defaults to same namespace)
+
false
+ + +### SonarrCustomFormat.spec.specifications[index] +[↩ Parent](#sonarrcustomformatspec) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
implementationenum + Specification type/implementation
+
+ Enum: releaseTitleSpecification, sourceSpecification, resolutionSpecification, qualityModifierSpecification, sizeSpecification, indexerFlagSpecification, languageSpecification, releaseGroupSpecification, editionSpecification
+
true
namestring + Specification name
+
true
fieldsobject + Fields/values for this specification
+
+ Default: map[max: min: value:]
+
false
negateboolean + Negate this condition
+
+ Default: false
+
false
requiredboolean + This condition is required
+
+ Default: true
+
false
+ + +### SonarrCustomFormat.spec.specifications[index].fields +[↩ Parent](#sonarrcustomformatspecspecificationsindex) + + + +Fields/values for this specification + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
maxnumber + Maximum value (for size specifications)
+
+ Format: double
+
false
minnumber + Minimum value (for size specifications)
+
+ Format: double
+
false
valuestring + Value for the specification (regex pattern, source type, etc.)
+
false
+ + +### SonarrCustomFormat.status +[↩ Parent](#sonarrcustomformat) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
conditions[]object + Current conditions
+
+ Default: []
+
false
idinteger + Sonarr Custom Format ID
+
+ Format: int32
+
false
observedGenerationinteger + Observed generation
+
+ Format: int64
+ Default: 0
+
false
+ + +### SonarrCustomFormat.status.conditions[index] +[↩ Parent](#sonarrcustomformatstatus) + + + +Condition contains details for one aspect of the current state of this API Resource. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
lastTransitionTimestring + lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+
+ Format: date-time
+
true
messagestring + message is a human readable message indicating details about the transition. This may be an empty string.
+
true
reasonstring + reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty.
+
true
statusstring + status of the condition, one of True, False, Unknown.
+
true
typestring + type of condition in CamelCase or in foo.example.com/CamelCase.
+
true
observedGenerationinteger + observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance.
+
+ Format: int64
+
false
+ +## SonarrDelayProfile +[↩ Parent](#devopsarriov1alpha1 ) + + + + + + +Auto-generated derived type for SonarrDelayProfileSpec via `CustomResource` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
apiVersionstringdevopsarr.io/v1alpha1true
kindstringSonarrDelayProfiletrue
metadataobjectRefer to the Kubernetes API documentation for the fields of the `metadata` field.true
specobject + SonarrDelayProfile represents a delay profile configuration in Sonarr +Delay profiles control how long Sonarr waits before grabbing a release
+
true
statusobject +
+
false
+ + +### SonarrDelayProfile.spec +[↩ Parent](#sonarrdelayprofile) + + + +SonarrDelayProfile represents a delay profile configuration in Sonarr +Delay profiles control how long Sonarr waits before grabbing a release + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
sonarrInstanceRefobject + Reference to the SonarrInstance
+
true
bypassIfAboveCustomFormatScoreboolean + Bypass delay if above custom format score
+
+ Default: false
+
false
bypassIfHighestQualityboolean + Bypass delay if highest quality
+
+ Default: false
+
false
enableTorrentboolean + Enable Torrent downloads
+
+ Default: true
+
false
enableUsenetboolean + Enable Usenet downloads
+
+ Default: true
+
false
minimumCustomFormatScoreinteger + Minimum custom format score to bypass delay
+
+ Format: int32
+ Default: 0
+
false
orderinteger + Order of this profile (lower = higher priority)
+
+ Format: int32
+ Default: 0
+
false
preferredProtocolenum + Preferred download protocol
+
+ Enum: usenet, torrent
+ Default: usenet
+
false
tags[]integer + Tags to apply this delay profile to
+
+ Default: []
+
false
torrentDelayinteger + Delay for Torrents in minutes
+
+ Format: int32
+ Default: 0
+
false
usenetDelayinteger + Delay for Usenet in minutes
+
+ Format: int32
+ Default: 0
+
false
+ + +### SonarrDelayProfile.spec.sonarrInstanceRef +[↩ Parent](#sonarrdelayprofilespec) + + + +Reference to the SonarrInstance + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring + Name of the SonarrInstance resource
+
+ Default:
+
false
namespacestring + Namespace of the SonarrInstance (optional, defaults to same namespace)
+
false
+ + +### SonarrDelayProfile.status +[↩ Parent](#sonarrdelayprofile) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
conditions[]object + Current conditions
+
+ Default: []
+
false
idinteger + Sonarr Delay Profile ID
+
+ Format: int32
+
false
observedGenerationinteger + Observed generation
+
+ Format: int64
+ Default: 0
+
false
+ + +### SonarrDelayProfile.status.conditions[index] +[↩ Parent](#sonarrdelayprofilestatus) + + + +Condition contains details for one aspect of the current state of this API Resource. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
lastTransitionTimestring + lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+
+ Format: date-time
+
true
messagestring + message is a human readable message indicating details about the transition. This may be an empty string.
+
true
reasonstring + reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty.
+
true
statusstring + status of the condition, one of True, False, Unknown.
+
true
typestring + type of condition in CamelCase or in foo.example.com/CamelCase.
+
true
observedGenerationinteger + observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance.
+
+ Format: int64
+
false
+ +## SonarrDownloadClient +[↩ Parent](#devopsarriov1alpha1 ) + + + + + + +Auto-generated derived type for SonarrDownloadClientSpec via `CustomResource` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
apiVersionstringdevopsarr.io/v1alpha1true
kindstringSonarrDownloadClienttrue
metadataobjectRefer to the Kubernetes API documentation for the fields of the `metadata` field.true
specobject + SonarrDownloadClient represents a download client configuration in Sonarr +Download clients are used to download releases (qBittorrent, Transmission, SABnzbd, etc.)
+
true
statusobject +
+
false
+ + +### SonarrDownloadClient.spec +[↩ Parent](#sonarrdownloadclient) + + + +SonarrDownloadClient represents a download client configuration in Sonarr +Download clients are used to download releases (qBittorrent, Transmission, SABnzbd, etc.) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
configobject + Download client configuration
+
true
downloadClientTypeenum + Download client type
+
+ Enum: Aria2, Deluge, Flood, Hadouken, Nzbget, Nzbvortex, Pneumatic, QBittorrent, RTorrent, Sabnzbd, TorrentBlackhole, TorrentDownloadStation, Transmission, UsenetBlackhole, UsenetDownloadStation, UTorrent, Vuze
+
true
namestring + Download client name
+
true
sonarrInstanceRefobject + Reference to the SonarrInstance
+
true
enableboolean + Enable this download client
+
+ Default: true
+
false
priorityinteger + Priority for this download client
+
+ Format: int32
+ Default: 1
+
false
removeCompletedDownloadsboolean + Remove completed downloads
+
+ Default: true
+
false
removeFailedDownloadsboolean + Remove failed downloads
+
+ Default: true
+
false
tags[]integer + Tags for this download client
+
+ Default: []
+
false
+ + +### SonarrDownloadClient.spec.config +[↩ Parent](#sonarrdownloadclientspec) + + + +Download client configuration + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
addPausedboolean + Add paused
+
+ Default: false
+
false
apiKeySecretRefobject + API key from secret (for some clients)
+
false
firstAndLastboolean + First and last (for qBittorrent)
+
+ Default: false
+
false
hoststring + Host address
+
false
initialStateinteger + Initial state (for qBittorrent: 0 = Start, 1 = ForceStart, 2 = Pause)
+
+ Format: int32
+
false
nzbFolderstring + NZB folder (for blackhole)
+
false
olderTvPriorityinteger + Older TV priority (0 = Last, 1 = First)
+
+ Format: int32
+
false
passwordSecretRefobject + Password from secret
+
false
portinteger + Port number
+
+ Format: int32
+
false
recentTvPriorityinteger + Recent TV priority (0 = Last, 1 = First)
+
+ Format: int32
+
false
rpcPathstring + RPC path (for Aria2)
+
false
saveMagnetFilesboolean + Save magnet files (for blackhole)
+
+ Default: false
+
false
secretTokenSecretRefobject + Secret token (for Aria2)
+
false
sequentialOrderboolean + Sequential order (for qBittorrent)
+
+ Default: false
+
false
strmFolderstring + Strm folder (for pneumatic)
+
false
torrentFolderstring + Torrent folder (for blackhole)
+
false
tvCategorystring + TV category
+
false
tvDirectorystring + TV directory
+
false
urlBasestring + URL base path
+
false
useSslboolean + Use SSL
+
+ Default: false
+
false
usernamestring + Username
+
false
watchFolderstring + Watch folder (for blackhole)
+
false
+ + +### SonarrDownloadClient.spec.config.apiKeySecretRef +[↩ Parent](#sonarrdownloadclientspecconfig) + + + +API key from secret (for some clients) + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
keystring + Key in the secret
+
true
namestring + Name of the secret
+
true
+ + +### SonarrDownloadClient.spec.config.passwordSecretRef +[↩ Parent](#sonarrdownloadclientspecconfig) + + + +Password from secret + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
keystring + Key in the secret
+
true
namestring + Name of the secret
+
true
+ + +### SonarrDownloadClient.spec.config.secretTokenSecretRef +[↩ Parent](#sonarrdownloadclientspecconfig) + + + +Secret token (for Aria2) + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
keystring + Key in the secret
+
true
namestring + Name of the secret
+
true
+ + +### SonarrDownloadClient.spec.sonarrInstanceRef +[↩ Parent](#sonarrdownloadclientspec) + + + +Reference to the SonarrInstance + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring + Name of the SonarrInstance resource
+
+ Default:
+
false
namespacestring + Namespace of the SonarrInstance (optional, defaults to same namespace)
+
false
+ + +### SonarrDownloadClient.status +[↩ Parent](#sonarrdownloadclient) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
conditions[]object + Current conditions
+
+ Default: []
+
false
idinteger + Sonarr Download Client ID
+
+ Format: int32
+
false
observedGenerationinteger + Observed generation
+
+ Format: int64
+ Default: 0
+
false
+ + +### SonarrDownloadClient.status.conditions[index] +[↩ Parent](#sonarrdownloadclientstatus) + + + +Condition contains details for one aspect of the current state of this API Resource. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
lastTransitionTimestring + lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+
+ Format: date-time
+
true
messagestring + message is a human readable message indicating details about the transition. This may be an empty string.
+
true
reasonstring + reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty.
+
true
statusstring + status of the condition, one of True, False, Unknown.
+
true
typestring + type of condition in CamelCase or in foo.example.com/CamelCase.
+
true
observedGenerationinteger + observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance.
+
+ Format: int64
+
false
+ +## SonarrDownloadClientConfig +[↩ Parent](#devopsarriov1alpha1 ) + + + + + + +Auto-generated derived type for SonarrDownloadClientConfigSpec via `CustomResource` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
apiVersionstringdevopsarr.io/v1alpha1true
kindstringSonarrDownloadClientConfigtrue
metadataobjectRefer to the Kubernetes API documentation for the fields of the `metadata` field.true
specobject + SonarrDownloadClientConfig configures global download client settings for a Sonarr instance. +Only one SonarrDownloadClientConfig per Sonarr instance is allowed. +Note: This is different from SonarrDownloadClient which configures individual download clients.
+
true
statusobject +
+
false
+ + +### SonarrDownloadClientConfig.spec +[↩ Parent](#sonarrdownloadclientconfig) + + + +SonarrDownloadClientConfig configures global download client settings for a Sonarr instance. +Only one SonarrDownloadClientConfig per Sonarr instance is allowed. +Note: This is different from SonarrDownloadClient which configures individual download clients. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
sonarrInstanceRefobject + Reference to the Sonarr instance
+
true
autoRedownloadFailedboolean + Automatically redownload failed releases
+
false
autoRedownloadFailedFromInteractiveSearchboolean + Automatically redownload failed releases from interactive search
+
false
downloadClientWorkingFoldersstring + Working folders for download client (container path mapping)
+
false
enableCompletedDownloadHandlingboolean + Enable completed download handling
+
false
+ + +### SonarrDownloadClientConfig.spec.sonarrInstanceRef +[↩ Parent](#sonarrdownloadclientconfigspec) + + + +Reference to the Sonarr instance + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring + Name of the SonarrInstance resource
+
+ Default:
+
false
namespacestring + Namespace of the SonarrInstance (optional, defaults to same namespace)
+
false
+ + +### SonarrDownloadClientConfig.status +[↩ Parent](#sonarrdownloadclientconfig) + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
conditions[]object + Current conditions
+
+ Default: []
+
false
observedGenerationinteger + Observed generation
+
+ Format: int64
+ Default: 0
+
false
+ + +### SonarrDownloadClientConfig.status.conditions[index] +[↩ Parent](#sonarrdownloadclientconfigstatus) + + + +Condition contains details for one aspect of the current state of this API Resource. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
lastTransitionTimestring + lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+
+ Format: date-time
+
true
messagestring + message is a human readable message indicating details about the transition. This may be an empty string.
+
true
reasonstring + reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty.
+
true
statusstring + status of the condition, one of True, False, Unknown.
+
true
typestring + type of condition in CamelCase or in foo.example.com/CamelCase.
+
true
observedGenerationinteger + observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance.
+
+ Format: int64
+
false
+ +## SonarrImportList +[↩ Parent](#devopsarriov1alpha1 ) + + + + + + +Auto-generated derived type for SonarrImportListSpec via `CustomResource` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
apiVersionstringdevopsarr.io/v1alpha1true
kindstringSonarrImportListtrue
metadataobjectRefer to the Kubernetes API documentation for the fields of the `metadata` field.true
specobject + SonarrImportList represents an import list configuration in Sonarr +Import lists automatically add series from external sources (Trakt, Plex, etc.)
+
true
statusobject +
+
false
+ + +### SonarrImportList.spec +[↩ Parent](#sonarrimportlist) + + + +SonarrImportList represents an import list configuration in Sonarr +Import lists automatically add series from external sources (Trakt, Plex, etc.) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
listTypeenum + Import list type/implementation
+
+ Enum: sonarrImport, traktListImport, traktUserImport, traktPopularImport, plexImport, imdbListImport, customImport, simklImport, aniListImport, myAnimeListImport
+
true
namestring + Import list name
+
true
qualityProfileIdinteger + Quality profile ID to use
+
+ Format: int32
+
true
rootFolderPathstring + Root folder path for imported series
+
true
sonarrInstanceRefobject + Reference to the SonarrInstance
+
true
configobject + Import list configuration
+
+ Default: map[accessToken: apiKey: authUser: baseUrl: languageProfileId: listId: listname: profileIds:[] tagIds:[] traktListType: username:]
+
false
enableAutomaticAddboolean + Enable automatic add
+
+ Default: true
+
false
listOrderinteger + List order
+
+ Format: int32
+ Default: 0
+
false
monitorNewItemsenum + Monitor new items
+
+ Enum: all, none
+ Default: all
+
false
searchForMissingEpisodesboolean + Search for missing episodes when adding
+
+ Default: false
+
false
seasonFolderboolean + Use season folders
+
+ Default: true
+
false
seriesTypeenum + Series type
+
+ Enum: standard, daily, anime
+ Default: standard
+
false
shouldMonitorenum + Monitor type for imported series
+
+ Enum: all, future, missing, existing, firstSeason, latestSeason, pilot, monitorSpecials, unmonitorSpecials, none
+ Default: all
+
false
tags[]integer + Tags for imported series
+
+ Default: []
+
false
+ + +### SonarrImportList.spec.sonarrInstanceRef +[↩ Parent](#sonarrimportlistspec) + + + +Reference to the SonarrInstance + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring + Name of the SonarrInstance resource
+
+ Default:
+
false
namespacestring + Namespace of the SonarrInstance (optional, defaults to same namespace)
+
false
+ + +### SonarrImportList.spec.config +[↩ Parent](#sonarrimportlistspec) + + + +Import list configuration + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
accessTokenstring + Access token (for Trakt/Plex)
+
false
apiKeystring + API key (for Sonarr import)
+
false
authUserstring + Auth user (for Trakt)
+
false
baseUrlstring + Base URL (for Sonarr import)
+
false
languageProfileIdinteger + Language profile ID (deprecated in v4)
+
+ Format: int32
+
false
listIdstring + List ID
+
false
listnamestring + List name/ID
+
false
profileIds[]integer + Profile IDs (for Sonarr import)
+
+ Default: []
+
false
tagIds[]integer + Tag IDs (for Sonarr import)
+
+ Default: []
+
false
traktListTypeinteger + Trakt list type
+
+ Format: int32
+
false
usernamestring + Username (for various services)
+
false
+ + +### SonarrImportList.status +[↩ Parent](#sonarrimportlist) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
conditions[]object + Current conditions
+
+ Default: []
+
false
idinteger + Sonarr Import List ID
+
+ Format: int32
+
false
observedGenerationinteger + Observed generation
+
+ Format: int64
+ Default: 0
+
false
+ + +### SonarrImportList.status.conditions[index] +[↩ Parent](#sonarrimportliststatus) + + + +Condition contains details for one aspect of the current state of this API Resource. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
lastTransitionTimestring + lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+
+ Format: date-time
+
true
messagestring + message is a human readable message indicating details about the transition. This may be an empty string.
+
true
reasonstring + reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty.
+
true
statusstring + status of the condition, one of True, False, Unknown.
+
true
typestring + type of condition in CamelCase or in foo.example.com/CamelCase.
+
true
observedGenerationinteger + observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance.
+
+ Format: int64
+
false
+ +## SonarrIndexer +[↩ Parent](#devopsarriov1alpha1 ) + + + + + + +Auto-generated derived type for SonarrIndexerSpec via `CustomResource` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
apiVersionstringdevopsarr.io/v1alpha1true
kindstringSonarrIndexertrue
metadataobjectRefer to the Kubernetes API documentation for the fields of the `metadata` field.true
specobject + SonarrIndexer represents an indexer configuration in Sonarr +Indexers are sources for finding releases (Newznab, Torznab, etc.)
+
true
statusobject +
+
false
+ + +### SonarrIndexer.spec +[↩ Parent](#sonarrindexer) + + + +SonarrIndexer represents an indexer configuration in Sonarr +Indexers are sources for finding releases (Newznab, Torznab, etc.) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
configobject + Indexer-specific configuration
+
true
indexerTypeenum + Indexer type (Newznab, Torznab, etc.)
+
+ Enum: newznab, torznab, fanzub, broadcasthenet, filelist, hdbits, iptorrents, nyaa, torrentrss, torrentleech
+
true
namestring + Indexer name
+
true
sonarrInstanceRefobject + Reference to the SonarrInstance
+
true
downloadClientIdinteger + Download client ID to use
+
+ Format: int32
+
false
enableAutomaticSearchboolean + Enable automatic search
+
+ Default: true
+
false
enableInteractiveSearchboolean + Enable interactive search
+
+ Default: true
+
false
enableRssboolean + Enable RSS feeds
+
+ Default: true
+
false
priorityinteger + Priority for this indexer
+
+ Format: int32
+ Default: 25
+
false
tags[]integer + Tags for this indexer
+
+ Default: []
+
false
+ + +### SonarrIndexer.spec.config +[↩ Parent](#sonarrindexerspec) + + + +Indexer-specific configuration + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
additionalParametersstring + Additional parameters
+
false
animeCategories[]integer + Anime categories
+
+ Default: []
+
false
animeStandardFormatSearchboolean + Search anime in standard format
+
+ Default: false
+
false
apiKeystring + API key (can reference a secret)
+
false
apiKeySecretRefobject + API key from secret reference
+
false
apiPathstring + API path (default: /api)
+
false
baseUrlstring + Base URL for the indexer
+
false
categories[]integer + Categories to search
+
+ Default: []
+
false
cookiestring + Cookie (for some indexers)
+
false
minimumSeedersinteger + Minimum seeders (for torrent indexers)
+
+ Format: int32
+
false
passkeystring + Passkey (for some indexers)
+
false
passwordSecretRefobject + Password secret reference (for some indexers)
+
false
seedRationumber + Seed ratio (for torrent indexers)
+
+ Format: double
+
false
seedTimeinteger + Seed time (for torrent indexers)
+
+ Format: int32
+
false
usernamestring + Username (for some indexers)
+
false
+ + +### SonarrIndexer.spec.config.apiKeySecretRef +[↩ Parent](#sonarrindexerspecconfig) + + + +API key from secret reference + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
keystring + Key in the secret
+
true
namestring + Name of the secret
+
true
+ + +### SonarrIndexer.spec.config.passwordSecretRef +[↩ Parent](#sonarrindexerspecconfig) + + + +Password secret reference (for some indexers) + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
keystring + Key in the secret
+
true
namestring + Name of the secret
+
true
+ + +### SonarrIndexer.spec.sonarrInstanceRef +[↩ Parent](#sonarrindexerspec) + + + +Reference to the SonarrInstance + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring + Name of the SonarrInstance resource
+
+ Default:
+
false
namespacestring + Namespace of the SonarrInstance (optional, defaults to same namespace)
+
false
+ + +### SonarrIndexer.status +[↩ Parent](#sonarrindexer) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
conditions[]object + Current conditions
+
+ Default: []
+
false
idinteger + Sonarr Indexer ID
+
+ Format: int32
+
false
observedGenerationinteger + Observed generation
+
+ Format: int64
+ Default: 0
+
false
+ + +### SonarrIndexer.status.conditions[index] +[↩ Parent](#sonarrindexerstatus) + + + +Condition contains details for one aspect of the current state of this API Resource. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
lastTransitionTimestring + lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+
+ Format: date-time
+
true
messagestring + message is a human readable message indicating details about the transition. This may be an empty string.
+
true
reasonstring + reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty.
+
true
statusstring + status of the condition, one of True, False, Unknown.
+
true
typestring + type of condition in CamelCase or in foo.example.com/CamelCase.
+
true
observedGenerationinteger + observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance.
+
+ Format: int64
+
false
+ +## SonarrIndexerConfig +[↩ Parent](#devopsarriov1alpha1 ) + + + + + + +Auto-generated derived type for SonarrIndexerConfigSpec via `CustomResource` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
apiVersionstringdevopsarr.io/v1alpha1true
kindstringSonarrIndexerConfigtrue
metadataobjectRefer to the Kubernetes API documentation for the fields of the `metadata` field.true
specobject + SonarrIndexerConfig configures global indexer settings for a Sonarr instance. +Only one SonarrIndexerConfig per Sonarr instance is allowed. +Note: This is different from SonarrIndexer which configures individual indexers.
+
true
statusobject +
+
false
+ + +### SonarrIndexerConfig.spec +[↩ Parent](#sonarrindexerconfig) + + + +SonarrIndexerConfig configures global indexer settings for a Sonarr instance. +Only one SonarrIndexerConfig per Sonarr instance is allowed. +Note: This is different from SonarrIndexer which configures individual indexers. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
sonarrInstanceRefobject + Reference to the Sonarr instance
+
true
maximumSizeinteger + Maximum release size in MB (0 = unlimited)
+
+ Format: int32
+
false
minimumAgeinteger + Minimum age in minutes before downloading (usenet)
+
+ Format: int32
+
false
retentioninteger + Retention in days (0 = unlimited)
+
+ Format: int32
+
false
rssSyncIntervalinteger + RSS sync interval in minutes (0 = disabled, minimum 10)
+
+ Format: int32
+
false
+ + +### SonarrIndexerConfig.spec.sonarrInstanceRef +[↩ Parent](#sonarrindexerconfigspec) + + + +Reference to the Sonarr instance + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring + Name of the SonarrInstance resource
+
+ Default:
+
false
namespacestring + Namespace of the SonarrInstance (optional, defaults to same namespace)
+
false
+ + +### SonarrIndexerConfig.status +[↩ Parent](#sonarrindexerconfig) + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
conditions[]object + Current conditions
+
+ Default: []
+
false
observedGenerationinteger + Observed generation
+
+ Format: int64
+ Default: 0
+
false
+ + +### SonarrIndexerConfig.status.conditions[index] +[↩ Parent](#sonarrindexerconfigstatus) + + + +Condition contains details for one aspect of the current state of this API Resource. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
lastTransitionTimestring + lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+
+ Format: date-time
+
true
messagestring + message is a human readable message indicating details about the transition. This may be an empty string.
+
true
reasonstring + reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty.
+
true
statusstring + status of the condition, one of True, False, Unknown.
+
true
typestring + type of condition in CamelCase or in foo.example.com/CamelCase.
+
true
observedGenerationinteger + observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance.
+
+ Format: int64
+
false
+ +## SonarrLanguageProfile +[↩ Parent](#devopsarriov1alpha1 ) + + + + + + +Auto-generated derived type for SonarrLanguageProfileSpec via `CustomResource` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
apiVersionstringdevopsarr.io/v1alpha1true
kindstringSonarrLanguageProfiletrue
metadataobjectRefer to the Kubernetes API documentation for the fields of the `metadata` field.true
specobject + SonarrLanguageProfile represents a language profile configuration in Sonarr +Language profiles define preferred languages for downloading series +Note: Deprecated in Sonarr v4, replaced by per-series language selection
+
true
statusobject +
+
false
+ + +### SonarrLanguageProfile.spec +[↩ Parent](#sonarrlanguageprofile) + + + +SonarrLanguageProfile represents a language profile configuration in Sonarr +Language profiles define preferred languages for downloading series +Note: Deprecated in Sonarr v4, replaced by per-series language selection + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
cutoffLanguageenum + Cutoff language - stop upgrading when this language is reached
+
+ Enum: Unknown, English, French, Spanish, German, Italian, Danish, Dutch, Japanese, Icelandic, Chinese, Russian, Polish, Vietnamese, Swedish, Norwegian, Finnish, Turkish, Portuguese, Flemish, Greek, Korean, Hungarian, Hebrew, Lithuanian, Czech, Hindi, Romanian, Thai, Bulgarian, PortugueseBrazil, Arabic, Ukrainian, Persian, Bengali, Slovak, Latvian, SpanishLatino, Catalan, Croatian, Serbian, Bosnian, Estonian, Tamil, Indonesian, Telugu, Macedonian, Slovenian, Malay, Original, Any
+
true
languages[]object + Ordered list of languages (first = highest priority)
+
true
namestring + Language profile name
+
true
sonarrInstanceRefobject + Reference to the SonarrInstance
+
true
upgradeAllowedboolean + Allow upgrades to better quality languages
+
+ Default: false
+
false
+ + +### SonarrLanguageProfile.spec.languages[index] +[↩ Parent](#sonarrlanguageprofilespec) + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
languageenum + Language
+
+ Enum: Unknown, English, French, Spanish, German, Italian, Danish, Dutch, Japanese, Icelandic, Chinese, Russian, Polish, Vietnamese, Swedish, Norwegian, Finnish, Turkish, Portuguese, Flemish, Greek, Korean, Hungarian, Hebrew, Lithuanian, Czech, Hindi, Romanian, Thai, Bulgarian, PortugueseBrazil, Arabic, Ukrainian, Persian, Bengali, Slovak, Latvian, SpanishLatino, Catalan, Croatian, Serbian, Bosnian, Estonian, Tamil, Indonesian, Telugu, Macedonian, Slovenian, Malay, Original, Any
+
true
allowedboolean + Whether this language is allowed
+
+ Default: true
+
false
+ + +### SonarrLanguageProfile.spec.sonarrInstanceRef +[↩ Parent](#sonarrlanguageprofilespec) + + + +Reference to the SonarrInstance + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring + Name of the SonarrInstance resource
+
+ Default:
+
false
namespacestring + Namespace of the SonarrInstance (optional, defaults to same namespace)
+
false
+ + +### SonarrLanguageProfile.status +[↩ Parent](#sonarrlanguageprofile) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
conditions[]object + Current conditions
+
+ Default: []
+
false
idinteger + Sonarr Language Profile ID
+
+ Format: int32
+
false
observedGenerationinteger + Observed generation
+
+ Format: int64
+ Default: 0
+
false
+ + +### SonarrLanguageProfile.status.conditions[index] +[↩ Parent](#sonarrlanguageprofilestatus) + + + +Condition contains details for one aspect of the current state of this API Resource. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
lastTransitionTimestring + lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+
+ Format: date-time
+
true
messagestring + message is a human readable message indicating details about the transition. This may be an empty string.
+
true
reasonstring + reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty.
+
true
statusstring + status of the condition, one of True, False, Unknown.
+
true
typestring + type of condition in CamelCase or in foo.example.com/CamelCase.
+
true
observedGenerationinteger + observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance.
+
+ Format: int64
+
false
+ +## SonarrMediaManagementConfig +[↩ Parent](#devopsarriov1alpha1 ) + + + + + + +Auto-generated derived type for SonarrMediaManagementConfigSpec via `CustomResource` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
apiVersionstringdevopsarr.io/v1alpha1true
kindstringSonarrMediaManagementConfigtrue
metadataobjectRefer to the Kubernetes API documentation for the fields of the `metadata` field.true
specobject + SonarrMediaManagementConfig configures media management settings for a Sonarr instance. +Only one SonarrMediaManagementConfig per Sonarr instance is allowed.
+
true
statusobject +
+
false
+ + +### SonarrMediaManagementConfig.spec +[↩ Parent](#sonarrmediamanagementconfig) + + + +SonarrMediaManagementConfig configures media management settings for a Sonarr instance. +Only one SonarrMediaManagementConfig per Sonarr instance is allowed. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
sonarrInstanceRefobject + Reference to the Sonarr instance
+
true
autoUnmonitorPreviouslyDownloadedEpisodesboolean + Auto unmonitor previously downloaded episodes when marked as deleted
+
false
chmodFolderstring + chmod folder permissions (e.g., "755")
+
false
chownGroupstring + chown group
+
false
copyUsingHardlinksboolean + Use hardlinks instead of copy when possible
+
false
createEmptySeriesFoldersboolean + Create empty series folders during disk scan
+
false
deleteEmptyFoldersboolean + Delete empty series and season folders during disk scan
+
false
downloadPropersAndRepacksenum + Download propers and repacks: DoNotPrefer, PreferAndUpgrade, DoNotUpgrade
+
+ Enum: DoNotPrefer, PreferAndUpgrade, DoNotUpgrade
+
false
enableMediaInfoboolean + Enable media info scanning
+
false
episodeTitleRequiredenum + Episode title required: Always, BulkSeasonReleases, Never
+
+ Enum: Always, BulkSeasonReleases, Never
+
false
extraFileExtensionsstring + Extra file extensions to import (e.g., "srt,sub")
+
false
fileDateenum + File date to use: None, LocalAirDate, UtcAirDate
+
+ Enum: None, LocalAirDate, UtcAirDate
+
false
importExtraFilesboolean + Import extra files (subtitles, etc.)
+
false
minimumFreeSpaceWhenImportinginteger + Minimum free space when importing (MB)
+
+ Format: int32
+
false
recycleBinstring + Recycle bin path (empty to disable)
+
false
recycleBinCleanupDaysinteger + Days to keep files in recycle bin before cleaning (0 to disable)
+
+ Format: int32
+
false
rescanAfterRefreshenum + Rescan series folder after refresh: Always, AfterManual, Never
+
+ Enum: Always, AfterManual, Never
+
false
scriptImportPathstring + Script import path
+
false
setPermissionsLinuxboolean + Set permissions on Linux/macOS
+
false
skipFreeSpaceCheckWhenImportingboolean + Skip free space check when importing
+
false
useScriptImportboolean + Use script for importing
+
false
+ + +### SonarrMediaManagementConfig.spec.sonarrInstanceRef +[↩ Parent](#sonarrmediamanagementconfigspec) + + + +Reference to the Sonarr instance + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring + Name of the SonarrInstance resource
+
+ Default:
+
false
namespacestring + Namespace of the SonarrInstance (optional, defaults to same namespace)
+
false
+ + +### SonarrMediaManagementConfig.status +[↩ Parent](#sonarrmediamanagementconfig) + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
conditions[]object + Current conditions
+
+ Default: []
+
false
observedGenerationinteger + Observed generation
+
+ Format: int64
+ Default: 0
+
false
+ + +### SonarrMediaManagementConfig.status.conditions[index] +[↩ Parent](#sonarrmediamanagementconfigstatus) + + + +Condition contains details for one aspect of the current state of this API Resource. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
lastTransitionTimestring + lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+
+ Format: date-time
+
true
messagestring + message is a human readable message indicating details about the transition. This may be an empty string.
+
true
reasonstring + reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty.
+
true
statusstring + status of the condition, one of True, False, Unknown.
+
true
typestring + type of condition in CamelCase or in foo.example.com/CamelCase.
+
true
observedGenerationinteger + observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance.
+
+ Format: int64
+
false
+ +## SonarrMetadata +[↩ Parent](#devopsarriov1alpha1 ) + + + + + + +Auto-generated derived type for SonarrMetadataSpec via `CustomResource` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
apiVersionstringdevopsarr.io/v1alpha1true
kindstringSonarrMetadatatrue
metadataobjectRefer to the Kubernetes API documentation for the fields of the `metadata` field.true
specobject + SonarrMetadata represents a metadata consumer configuration in Sonarr +Metadata consumers write metadata files for media managers (Kodi, Plex, etc.)
+
true
statusobject +
+
false
+ + +### SonarrMetadata.spec +[↩ Parent](#sonarrmetadata) + + + +SonarrMetadata represents a metadata consumer configuration in Sonarr +Metadata consumers write metadata files for media managers (Kodi, Plex, etc.) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
metadataTypeenum + Metadata type/implementation
+
+ Enum: xbmcMetadata, roksboxMetadata, wdtvMetadata
+
true
namestring + Metadata consumer name
+
true
sonarrInstanceRefobject + Reference to the SonarrInstance
+
true
configobject + Metadata-specific configuration
+
+ Default: map[episodeImages:false episodeMetadata:false seasonImages:false seriesImages:false seriesMetadata:false seriesMetadataUrl:false]
+
false
enableboolean + Enable this metadata consumer
+
+ Default: true
+
false
tags[]integer + Tags for this metadata consumer
+
+ Default: []
+
false
+ + +### SonarrMetadata.spec.sonarrInstanceRef +[↩ Parent](#sonarrmetadataspec) + + + +Reference to the SonarrInstance + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring + Name of the SonarrInstance resource
+
+ Default:
+
false
namespacestring + Namespace of the SonarrInstance (optional, defaults to same namespace)
+
false
+ + +### SonarrMetadata.spec.config +[↩ Parent](#sonarrmetadataspec) + + + +Metadata-specific configuration + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
episodeImagesboolean + Write episode images (thumbnails)
+
+ Default: false
+
false
episodeMetadataboolean + Write episode metadata (episode.nfo)
+
+ Default: true
+
false
seasonImagesboolean + Write season images
+
+ Default: true
+
false
seriesImagesboolean + Write series images (poster, banner, fanart)
+
+ Default: true
+
false
seriesMetadataboolean + Write series metadata (series.nfo)
+
+ Default: true
+
false
seriesMetadataUrlboolean + Write series metadata URL (deprecated)
+
+ Default: false
+
false
+ + +### SonarrMetadata.status +[↩ Parent](#sonarrmetadata) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
conditions[]object + Current conditions
+
+ Default: []
+
false
idinteger + Sonarr Metadata ID
+
+ Format: int32
+
false
observedGenerationinteger + Observed generation
+
+ Format: int64
+ Default: 0
+
false
+ + +### SonarrMetadata.status.conditions[index] +[↩ Parent](#sonarrmetadatastatus) + + + +Condition contains details for one aspect of the current state of this API Resource. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
lastTransitionTimestring + lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+
+ Format: date-time
+
true
messagestring + message is a human readable message indicating details about the transition. This may be an empty string.
+
true
reasonstring + reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty.
+
true
statusstring + status of the condition, one of True, False, Unknown.
+
true
typestring + type of condition in CamelCase or in foo.example.com/CamelCase.
+
true
observedGenerationinteger + observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance.
+
+ Format: int64
+
false
+ +## SonarrNamingConfig +[↩ Parent](#devopsarriov1alpha1 ) + + + + + + +Auto-generated derived type for SonarrNamingConfigSpec via `CustomResource` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
apiVersionstringdevopsarr.io/v1alpha1true
kindstringSonarrNamingConfigtrue
metadataobjectRefer to the Kubernetes API documentation for the fields of the `metadata` field.true
specobject + SonarrNamingConfig configures episode naming settings for a Sonarr instance. +Only one SonarrNamingConfig per Sonarr instance is allowed.
+
true
statusobject +
+
false
+ + +### SonarrNamingConfig.spec +[↩ Parent](#sonarrnamingconfig) + + + +SonarrNamingConfig configures episode naming settings for a Sonarr instance. +Only one SonarrNamingConfig per Sonarr instance is allowed. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
sonarrInstanceRefobject + Reference to the Sonarr instance
+
true
animeEpisodeFormatstring + Anime episode format +Example: "{Series Title} - S{season:00}E{episode:00} - {Episode Title} {Quality Full}"
+
false
colonReplacementFormatinteger + Colon replacement format (0=Delete, 1=Dash, 2=SpaceDash, 3=SpaceDashSpace, 4=Smart)
+
+ Format: int32
+
false
customColonReplacementFormatstring + Custom colon replacement format string
+
false
dailyEpisodeFormatstring + Daily episode format +Example: "{Series Title} - {Air-Date} - {Episode Title} {Quality Full}"
+
false
multiEpisodeStyleinteger + Multi-episode style (0=Extend, 1=Duplicate, 2=Repeat, 3=Scene, 4=Range, 5=PrefixedRange)
+
+ Format: int32
+
false
renameEpisodesboolean + Enable episode renaming
+
false
replaceIllegalCharactersboolean + Replace illegal characters in filenames
+
false
seasonFolderFormatstring + Season folder format +Example: "Season {season}"
+
false
seriesFolderFormatstring + Series folder format +Example: "{Series Title}"
+
false
specialsFolderFormatstring + Specials folder format +Example: "Specials"
+
false
standardEpisodeFormatstring + Standard episode format +Example: "{Series Title} - S{season:00}E{episode:00} - {Episode Title} {Quality Full}"
+
false
+ + +### SonarrNamingConfig.spec.sonarrInstanceRef +[↩ Parent](#sonarrnamingconfigspec) + + + +Reference to the Sonarr instance + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring + Name of the SonarrInstance resource
+
+ Default:
+
false
namespacestring + Namespace of the SonarrInstance (optional, defaults to same namespace)
+
false
+ + +### SonarrNamingConfig.status +[↩ Parent](#sonarrnamingconfig) + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
conditions[]object + Current conditions
+
+ Default: []
+
false
observedGenerationinteger + Observed generation
+
+ Format: int64
+ Default: 0
+
false
+ + +### SonarrNamingConfig.status.conditions[index] +[↩ Parent](#sonarrnamingconfigstatus) + + + +Condition contains details for one aspect of the current state of this API Resource. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
lastTransitionTimestring + lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+
+ Format: date-time
+
true
messagestring + message is a human readable message indicating details about the transition. This may be an empty string.
+
true
reasonstring + reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty.
+
true
statusstring + status of the condition, one of True, False, Unknown.
+
true
typestring + type of condition in CamelCase or in foo.example.com/CamelCase.
+
true
observedGenerationinteger + observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance.
+
+ Format: int64
+
false
+ +## SonarrNotification +[↩ Parent](#devopsarriov1alpha1 ) + + + + + + +Auto-generated derived type for SonarrNotificationSpec via `CustomResource` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
apiVersionstringdevopsarr.io/v1alpha1true
kindstringSonarrNotificationtrue
metadataobjectRefer to the Kubernetes API documentation for the fields of the `metadata` field.true
specobject + SonarrNotification represents a notification/connect configuration in Sonarr +Notifications are used to alert on events (Discord, Telegram, Webhook, etc.)
+
true
statusobject +
+
false
+ + +### SonarrNotification.spec +[↩ Parent](#sonarrnotification) + + + +SonarrNotification represents a notification/connect configuration in Sonarr +Notifications are used to alert on events (Discord, Telegram, Webhook, etc.) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
configobject + Notification configuration
+
true
namestring + Notification name
+
true
notificationTypeenum + Notification type
+
+ Enum: Apprise, CustomScript, Discord, Email, Emby, Gotify, Join, Kodi, Mailgun, Ntfy, Plex, Prowl, Pushbullet, Pushover, SendGrid, Signal, Simplepush, Slack, SynologyIndexer, Telegram, Trakt, Twitter, Webhook
+
true
sonarrInstanceRefobject + Reference to the SonarrInstance
+
true
tags[]integer + Tags for this notification
+
+ Default: []
+
false
triggersobject + Event triggers
+
+ Default: map[includeHealthWarnings:false onApplicationUpdate:false onDownload:false onEpisodeFileDelete:false onEpisodeFileDeleteForUpgrade:false onGrab:false onHealthIssue:false onHealthRestored:false onImportComplete:false onManualInteractionRequired:false onRename:false onSeriesAdd:false onSeriesDelete:false onUpgrade:false]
+
false
+ + +### SonarrNotification.spec.config +[↩ Parent](#sonarrnotificationspec) + + + +Notification configuration + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
apiKeySecretRefobject + API key secret reference
+
false
appTokenSecretRefobject + Gotify app token secret reference
+
false
argumentsstring + Script arguments
+
false
authTokenSecretRefobject + Auth token secret reference
+
false
avatarstring + Discord avatar
+
false
bcc[]string + BCC addresses
+
+ Default: []
+
false
botTokenSecretRefobject + Telegram bot token secret reference
+
false
cc[]string + CC addresses
+
+ Default: []
+
false
channelstring + Slack channel
+
false
chatIdstring + Telegram chat ID
+
false
clickUrlstring + Click URL
+
false
devices[]string + Device list
+
+ Default: []
+
false
discordUsernamestring + Discord username
+
false
expireinteger + Expire after (seconds)
+
+ Format: int32
+
false
fromstring + From address
+
false
hoststring + Server host
+
false
iconstring + Slack icon
+
false
mapTostring + Notify on specific library sections
+
false
methodinteger + HTTP Method (1 = POST, 2 = PUT)
+
+ Format: int32
+
false
ntfyTags[]string + Ntfy tags
+
+ Default: []
+
false
passwordSecretRefobject + Password secret reference
+
false
pathstring + Path to script
+
false
portinteger + SMTP port
+
+ Format: int32
+
false
priorityinteger + Priority level
+
+ Format: int32
+
false
requireEncryptionboolean + Require encryption
+
+ Default: false
+
false
retryinteger + Retry interval (seconds)
+
+ Format: int32
+
false
sendSilentlyboolean + Send silently
+
+ Default: false
+
false
serverstring + SMTP server
+
false
serverUrlstring + Ntfy server URL
+
false
slackWebhookUrlstring + Slack webhook URL
+
false
soundstring + Sound
+
false
to[]string + To addresses
+
+ Default: []
+
false
topicstring + Ntfy topic
+
false
updateLibraryboolean + Update library
+
+ Default: false
+
false
urlstring + Webhook URL
+
false
useSslboolean + Use SSL
+
+ Default: false
+
false
userKeySecretRefobject + User key secret reference
+
false
usernamestring + Username for basic auth
+
false
webhookUrlstring + Discord webhook URL
+
false
+ + +### SonarrNotification.spec.config.apiKeySecretRef +[↩ Parent](#sonarrnotificationspecconfig) + + + +API key secret reference + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
keystring + Key in the secret
+
true
namestring + Name of the secret
+
true
+ + +### SonarrNotification.spec.config.appTokenSecretRef +[↩ Parent](#sonarrnotificationspecconfig) + + + +Gotify app token secret reference + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
keystring + Key in the secret
+
true
namestring + Name of the secret
+
true
+ + +### SonarrNotification.spec.config.authTokenSecretRef +[↩ Parent](#sonarrnotificationspecconfig) + + + +Auth token secret reference + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
keystring + Key in the secret
+
true
namestring + Name of the secret
+
true
+ + +### SonarrNotification.spec.config.botTokenSecretRef +[↩ Parent](#sonarrnotificationspecconfig) + + + +Telegram bot token secret reference + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
keystring + Key in the secret
+
true
namestring + Name of the secret
+
true
+ + +### SonarrNotification.spec.config.passwordSecretRef +[↩ Parent](#sonarrnotificationspecconfig) + + + +Password secret reference + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
keystring + Key in the secret
+
true
namestring + Name of the secret
+
true
+ + +### SonarrNotification.spec.config.userKeySecretRef +[↩ Parent](#sonarrnotificationspecconfig) + + + +User key secret reference + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
keystring + Key in the secret
+
true
namestring + Name of the secret
+
true
+ + +### SonarrNotification.spec.sonarrInstanceRef +[↩ Parent](#sonarrnotificationspec) + + + +Reference to the SonarrInstance + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring + Name of the SonarrInstance resource
+
+ Default:
+
false
namespacestring + Namespace of the SonarrInstance (optional, defaults to same namespace)
+
false
+ + +### SonarrNotification.spec.triggers +[↩ Parent](#sonarrnotificationspec) + + + +Event triggers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
includeHealthWarningsboolean + Include health warnings
+
+ Default: false
+
false
onApplicationUpdateboolean + On application update
+
+ Default: false
+
false
onDownloadboolean + On download (episode is downloaded)
+
+ Default: false
+
false
onEpisodeFileDeleteboolean + On episode file delete
+
+ Default: false
+
false
onEpisodeFileDeleteForUpgradeboolean + On episode file delete for upgrade
+
+ Default: false
+
false
onGrabboolean + On grab (episode is grabbed)
+
+ Default: false
+
false
onHealthIssueboolean + On health issue
+
+ Default: false
+
false
onHealthRestoredboolean + On health restored
+
+ Default: false
+
false
onImportCompleteboolean + On import complete
+
+ Default: false
+
false
onManualInteractionRequiredboolean + On manual interaction required
+
+ Default: false
+
false
onRenameboolean + On rename
+
+ Default: false
+
false
onSeriesAddboolean + On series add
+
+ Default: false
+
false
onSeriesDeleteboolean + On series delete
+
+ Default: false
+
false
onUpgradeboolean + On upgrade (episode is upgraded)
+
+ Default: false
+
false
+ + +### SonarrNotification.status +[↩ Parent](#sonarrnotification) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
conditions[]object + Current conditions
+
+ Default: []
+
false
idinteger + Sonarr Notification ID
+
+ Format: int32
+
false
observedGenerationinteger + Observed generation
+
+ Format: int64
+ Default: 0
+
false
+ + +### SonarrNotification.status.conditions[index] +[↩ Parent](#sonarrnotificationstatus) + + + +Condition contains details for one aspect of the current state of this API Resource. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
lastTransitionTimestring + lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+
+ Format: date-time
+
true
messagestring + message is a human readable message indicating details about the transition. This may be an empty string.
+
true
reasonstring + reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty.
+
true
statusstring + status of the condition, one of True, False, Unknown.
+
true
typestring + type of condition in CamelCase or in foo.example.com/CamelCase.
+
true
observedGenerationinteger + observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance.
+
+ Format: int64
+
false
+ +## SonarrQualityDefinition +[↩ Parent](#devopsarriov1alpha1 ) + + + + + + +Auto-generated derived type for SonarrQualityDefinitionSpec via `CustomResource` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
apiVersionstringdevopsarr.io/v1alpha1true
kindstringSonarrQualityDefinitiontrue
metadataobjectRefer to the Kubernetes API documentation for the fields of the `metadata` field.true
specobject + SonarrQualityDefinition represents a quality definition configuration in Sonarr +Quality definitions control the size limits for each quality level
+
true
statusobject +
+
false
+ + +### SonarrQualityDefinition.spec +[↩ Parent](#sonarrqualitydefinition) + + + +SonarrQualityDefinition represents a quality definition configuration in Sonarr +Quality definitions control the size limits for each quality level + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
qualityNameenum + Quality name (must match existing quality in Sonarr)
+
+ Enum: UNKNOWN, SDTV, DVD, WEBDL-480p, WEBRip-480p, Bluray-480p, HDTV-720p, HDTV-1080p, Raw-HD, WEBDL-720p, WEBRip-720p, Bluray-720p, WEBDL-1080p, WEBRip-1080p, Bluray-1080p, Bluray-1080p Remux, HDTV-2160p, WEBDL-2160p, WEBRip-2160p, Bluray-2160p, Bluray-2160p Remux
+
true
sonarrInstanceRefobject + Reference to the SonarrInstance
+
true
maxSizenumber + Maximum size in MB per minute of runtime (None = unlimited)
+
+ Format: double
+
false
minSizenumber + Minimum size in MB per minute of runtime
+
+ Format: double
+
false
preferredSizenumber + Preferred size in MB per minute of runtime
+
+ Format: double
+
false
titlestring + Title/display name for this quality
+
false
+ + +### SonarrQualityDefinition.spec.sonarrInstanceRef +[↩ Parent](#sonarrqualitydefinitionspec) + + + +Reference to the SonarrInstance + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring + Name of the SonarrInstance resource
+
+ Default:
+
false
namespacestring + Namespace of the SonarrInstance (optional, defaults to same namespace)
+
false
+ + +### SonarrQualityDefinition.status +[↩ Parent](#sonarrqualitydefinition) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
conditions[]object + Current conditions
+
+ Default: []
+
false
idinteger + Sonarr Quality Definition ID
+
+ Format: int32
+
false
observedGenerationinteger + Observed generation
+
+ Format: int64
+ Default: 0
+
false
+ + +### SonarrQualityDefinition.status.conditions[index] +[↩ Parent](#sonarrqualitydefinitionstatus) + + + +Condition contains details for one aspect of the current state of this API Resource. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
lastTransitionTimestring + lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+
+ Format: date-time
+
true
messagestring + message is a human readable message indicating details about the transition. This may be an empty string.
+
true
reasonstring + reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty.
+
true
statusstring + status of the condition, one of True, False, Unknown.
+
true
typestring + type of condition in CamelCase or in foo.example.com/CamelCase.
+
true
observedGenerationinteger + observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance.
+
+ Format: int64
+
false
+ +## SonarrQualityProfile +[↩ Parent](#devopsarriov1alpha1 ) + + + + + + +Auto-generated derived type for SonarrQualityProfileSpec via `CustomResource` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
apiVersionstringdevopsarr.io/v1alpha1true
kindstringSonarrQualityProfiletrue
metadataobjectRefer to the Kubernetes API documentation for the fields of the `metadata` field.true
specobject + SonarrQualityProfile represents a quality profile in Sonarr +Quality profiles define which qualities are acceptable and their priority
+
true
statusobject +
+
false
+ + +### SonarrQualityProfile.spec +[↩ Parent](#sonarrqualityprofile) + + + +SonarrQualityProfile represents a quality profile in Sonarr +Quality profiles define which qualities are acceptable and their priority + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring + Quality profile name
+
true
qualityGroups[]object + Ordered list of quality groups
+
true
sonarrInstanceRefobject + Reference to the SonarrInstance
+
true
cutoffinteger + Quality ID to use as cutoff
+
+ Format: int32
+ Default: 0
+
false
cutoffFormatScoreinteger + Cutoff format score
+
+ Format: int32
+
false
formatItems[]object + Format items (custom formats with scores)
+
+ Default: []
+
false
minFormatScoreinteger + Minimum format score
+
+ Format: int32
+
false
minUpgradeFormatScoreinteger + Minimum upgrade format score
+
+ Format: int32
+
false
upgradeAllowedboolean + Whether upgrades are allowed
+
+ Default: false
+
false
+ + +### SonarrQualityProfile.spec.qualityGroups[index] +[↩ Parent](#sonarrqualityprofilespec) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
qualities[]object + Ordered list of qualities in this group
+
true
idinteger + Quality group ID
+
+ Format: int32
+
false
namestring + Quality group name
+
false
+ + +### SonarrQualityProfile.spec.qualityGroups[index].qualities[index] +[↩ Parent](#sonarrqualityprofilespecqualitygroupsindex) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
idinteger + Quality ID
+
+ Format: int32
+
false
namestring + Quality name
+
false
resolutioninteger + Resolution
+
+ Format: int32
+
false
sourcestring + Source type
+
false
+ + +### SonarrQualityProfile.spec.sonarrInstanceRef +[↩ Parent](#sonarrqualityprofilespec) + + + +Reference to the SonarrInstance + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring + Name of the SonarrInstance resource
+
+ Default:
+
false
namespacestring + Namespace of the SonarrInstance (optional, defaults to same namespace)
+
false
+ + +### SonarrQualityProfile.spec.formatItems[index] +[↩ Parent](#sonarrqualityprofilespec) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
formatinteger + Custom format ID
+
+ Format: int32
+
false
namestring + Format name
+
false
scoreinteger + Score for this format
+
+ Format: int32
+ Default: 0
+
false
+ + +### SonarrQualityProfile.status +[↩ Parent](#sonarrqualityprofile) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
conditions[]object + Current conditions
+
+ Default: []
+
false
idinteger + Sonarr Quality Profile ID
+
+ Format: int32
+
false
observedGenerationinteger + Observed generation
+
+ Format: int64
+ Default: 0
+
false
+ + +### SonarrQualityProfile.status.conditions[index] +[↩ Parent](#sonarrqualityprofilestatus) + + + +Condition contains details for one aspect of the current state of this API Resource. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
lastTransitionTimestring + lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+
+ Format: date-time
+
true
messagestring + message is a human readable message indicating details about the transition. This may be an empty string.
+
true
reasonstring + reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty.
+
true
statusstring + status of the condition, one of True, False, Unknown.
+
true
typestring + type of condition in CamelCase or in foo.example.com/CamelCase.
+
true
observedGenerationinteger + observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance.
+
+ Format: int64
+
false
+ +## SonarrRootFolder +[↩ Parent](#devopsarriov1alpha1 ) + + + + + + +Auto-generated derived type for SonarrRootFolderSpec via `CustomResource` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
apiVersionstringdevopsarr.io/v1alpha1true
kindstringSonarrRootFoldertrue
metadataobjectRefer to the Kubernetes API documentation for the fields of the `metadata` field.true
specobject + SonarrRootFolder represents a root folder in Sonarr +Root folders are the base directories where series are stored
+
true
statusobject +
+
false
+ + +### SonarrRootFolder.spec +[↩ Parent](#sonarrrootfolder) + + + +SonarrRootFolder represents a root folder in Sonarr +Root folders are the base directories where series are stored + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
pathstring + Root folder absolute path
+
true
sonarrInstanceRefobject + Reference to the SonarrInstance
+
true
+ + +### SonarrRootFolder.spec.sonarrInstanceRef +[↩ Parent](#sonarrrootfolderspec) + + + +Reference to the SonarrInstance + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring + Name of the SonarrInstance resource
+
+ Default:
+
false
namespacestring + Namespace of the SonarrInstance (optional, defaults to same namespace)
+
false
+ + +### SonarrRootFolder.status +[↩ Parent](#sonarrrootfolder) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
accessibleboolean + Whether the folder is accessible
+
false
conditions[]object + Current conditions
+
+ Default: []
+
false
freeSpaceinteger + Free space in the folder
+
+ Format: int64
+
false
idinteger + Sonarr Root Folder ID
+
+ Format: int32
+
false
observedGenerationinteger + Observed generation
+
+ Format: int64
+ Default: 0
+
false
+ + +### SonarrRootFolder.status.conditions[index] +[↩ Parent](#sonarrrootfolderstatus) + + + +Condition contains details for one aspect of the current state of this API Resource. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
lastTransitionTimestring + lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+
+ Format: date-time
+
true
messagestring + message is a human readable message indicating details about the transition. This may be an empty string.
+
true
reasonstring + reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty.
+
true
statusstring + status of the condition, one of True, False, Unknown.
+
true
typestring + type of condition in CamelCase or in foo.example.com/CamelCase.
+
true
observedGenerationinteger + observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance.
+
+ Format: int64
+
false
+ +## SonarrSeries +[↩ Parent](#devopsarriov1alpha1 ) + + + + + + +Auto-generated derived type for SonarrSeriesSpec via `CustomResource` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
apiVersionstringdevopsarr.io/v1alpha1true
kindstringSonarrSeriestrue
metadataobjectRefer to the Kubernetes API documentation for the fields of the `metadata` field.true
specobject + SonarrSeries represents a TV series managed in Sonarr +This allows declarative management of series in your library
+
true
statusobject +
+
false
+ + +### SonarrSeries.spec +[↩ Parent](#sonarrseries) + + + +SonarrSeries represents a TV series managed in Sonarr +This allows declarative management of series in your library + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
qualityProfileobject + Quality profile ID or name reference
+
true
rootFolderPathstring + Root folder path for the series
+
true
sonarrInstanceRefobject + Reference to the SonarrInstance
+
true
titlestring + Series title
+
true
titleSlugstring + Title slug (kebab-case version of title)
+
true
tvdbIdinteger + TVDB ID for the series
+
+ Format: int32
+
true
addOptionsobject + Monitor type for adding series
+
+ Default: map[monitor:all searchForCutoffUnmetEpisodes:false searchForMissingEpisodes:true]
+
false
monitoredboolean + Whether the series is monitored
+
+ Default: true
+
false
pathstring + Specific path override (optional)
+
false
seasonFolderboolean + Use season folders
+
+ Default: true
+
false
seriesTypeenum + Series type
+
+ Enum: standard, daily, anime
+ Default: standard
+
false
tags[]integer + Tags for this series
+
+ Default: []
+
false
useSceneNumberingboolean + Use scene numbering
+
+ Default: false
+
false
+ + +### SonarrSeries.spec.qualityProfile +[↩ Parent](#sonarrseriesspec) + + + +Quality profile ID or name reference + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
idinteger + Quality profile ID
+
+ Format: int32
+
false
namestring + Quality profile name (will be resolved to ID)
+
false
+ + +### SonarrSeries.spec.sonarrInstanceRef +[↩ Parent](#sonarrseriesspec) + + + +Reference to the SonarrInstance + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring + Name of the SonarrInstance resource
+
+ Default:
+
false
namespacestring + Namespace of the SonarrInstance (optional, defaults to same namespace)
+
false
+ + +### SonarrSeries.spec.addOptions +[↩ Parent](#sonarrseriesspec) + + + +Monitor type for adding series + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
monitorenum + Monitor type
+
+ Enum: all, future, missing, existing, recent, pilot, firstseason, lastseason, none
+ Default: all
+
false
searchForCutoffUnmetEpisodesboolean + Search for cutoff unmet episodes
+
+ Default: false
+
false
searchForMissingEpisodesboolean + Search for missing episodes when adding
+
+ Default: true
+
false
+ + +### SonarrSeries.status +[↩ Parent](#sonarrseries) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
conditions[]object + Current conditions
+
+ Default: []
+
false
episodeCountinteger + Total episode count
+
+ Format: int32
+
false
episodeFileCountinteger + Episode file count
+
+ Format: int32
+
false
idinteger + Sonarr Series ID
+
+ Format: int32
+
false
networkstring + Network
+
false
nextAiringstring + Next airing date
+
false
observedGenerationinteger + Observed generation
+
+ Format: int64
+ Default: 0
+
false
percentCompletenumber + Percentage complete
+
+ Format: double
+
false
previousAiringstring + Previous airing date
+
false
seriesStatusstring + Status (continuing, ended, etc.)
+
false
+ + +### SonarrSeries.status.conditions[index] +[↩ Parent](#sonarrseriesstatus) + + + +Condition contains details for one aspect of the current state of this API Resource. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
lastTransitionTimestring + lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+
+ Format: date-time
+
true
messagestring + message is a human readable message indicating details about the transition. This may be an empty string.
+
true
reasonstring + reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty.
+
true
statusstring + status of the condition, one of True, False, Unknown.
+
true
typestring + type of condition in CamelCase or in foo.example.com/CamelCase.
+
true
observedGenerationinteger + observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance.
+
+ Format: int64
+
false
+ +## Sonarr +[↩ Parent](#devopsarriov1alpha1 ) + + + + + + +Auto-generated derived type for SonarrSpec via `CustomResource` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
apiVersionstringdevopsarr.io/v1alpha1true
kindstringSonarrtrue
metadataobjectRefer to the Kubernetes API documentation for the fields of the `metadata` field.true
specobject + Sonarr is the main CRD that deploys and manages a Sonarr instance + +This CRD creates: +- A Deployment with the Sonarr container +- An init container for database migrations +- A Service to expose Sonarr +- A PersistentVolumeClaim for configuration storage +- Optional Ingress for external access
+
true
statusobject +
+
false
+ + +### Sonarr.spec +[↩ Parent](#sonarr) + + + +Sonarr is the main CRD that deploys and manages a Sonarr instance + +This CRD creates: +- A Deployment with the Sonarr container +- An init container for database migrations +- A Service to expose Sonarr +- A PersistentVolumeClaim for configuration storage +- Optional Ingress for external access + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
apiKeySecretRefobject + API key secret reference (optional - will be auto-generated if not provided)
+
false
configobject + Sonarr application configuration (config.xml settings)
+
+ Default: map[analyticsEnabled: authenticationMethod: authenticationRequired: bindAddress: initContainerImage: instanceName: logLevel: urlBase:]
+
false
env[]object + Environment variables
+
+ Default: []
+
false
httpRouteobject + HTTPRoute configuration for Gateway API (optional)
+
false
imagestring + Sonarr image to use (default: lscr.io/linuxserver/sonarr:latest)
+
+ Default: lscr.io/linuxserver/sonarr:latest
+
false
imagePullPolicystring + Image pull policy (default: IfNotPresent)
+
+ Default: IfNotPresent
+
false
ingressobject + Ingress configuration (optional)
+
false
initContainerobject + Init container configuration (for custom init logic)
+
false
nodeSelectormap[string]string + Node selector
+
+ Default: map[]
+
false
replicasinteger + Number of replicas (should be 1 for Sonarr)
+
+ Format: int32
+ Default: 1
+
false
resourcesobject + Resource requirements
+
false
securityContextobject + Pod security context
+
false
serviceobject + Service configuration
+
+ Default: map[annotations:map[] containerPort:0 nodePort: port:0 serviceType:]
+
false
storageobject + Storage configuration
+
+ Default: map[accessModes:[] existingClaim: size: storageClass:]
+
false
tolerations[]object + Tolerations
+
+ Default: []
+
false
volumeMounts[]object + Volume mounts for media directories
+
+ Default: []
+
false
volumes[]object + Additional volumes
+
+ Default: []
+
false
+ + +### Sonarr.spec.apiKeySecretRef +[↩ Parent](#sonarrspec) + + + +API key secret reference (optional - will be auto-generated if not provided) + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
keystring + Key in the secret
+
true
namestring + Name of the secret
+
true
+ + +### Sonarr.spec.config +[↩ Parent](#sonarrspec) + + + +Sonarr application configuration (config.xml settings) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
analyticsEnabledboolean + Analytics enabled (default: true)
+
false
authenticationMethodstring + Authentication method: None, Basic, Forms, External (default: None)
+
false
authenticationRequiredboolean + Authentication required for API access (default: false)
+
false
bindAddressstring + Bind address (default: "*")
+
false
initContainerImagestring + Init container image used to configure config.xml (default: busybox:latest)
+
false
instanceNamestring + Instance name displayed in the UI
+
false
logLevelstring + Log level: trace, debug, info, warn, error (default: info)
+
false
urlBasestring + URL base for reverse proxy setups (e.g., "/sonarr")
+
false
+ + +### Sonarr.spec.env[index] +[↩ Parent](#sonarrspec) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring + Name of the environment variable
+
true
valuestring + Value of the environment variable
+
false
valueFromobject + Reference to a secret or configmap
+
false
+ + +### Sonarr.spec.env[index].valueFrom +[↩ Parent](#sonarrspecenvindex) + + + +Reference to a secret or configmap + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
configMapKeyRefobject + ConfigMap key reference
+
false
secretKeyRefobject + Secret key reference
+
false
+ + +### Sonarr.spec.env[index].valueFrom.configMapKeyRef +[↩ Parent](#sonarrspecenvindexvaluefrom) + + + +ConfigMap key reference + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
keystring + Key in the configmap
+
true
namestring + Name of the configmap
+
true
+ + +### Sonarr.spec.env[index].valueFrom.secretKeyRef +[↩ Parent](#sonarrspecenvindexvaluefrom) + + + +Secret key reference + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
keystring + Key in the secret
+
true
namestring + Name of the secret
+
true
+ + +### Sonarr.spec.httpRoute +[↩ Parent](#sonarrspec) + + + +HTTPRoute configuration for Gateway API (optional) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
gatewayRefobject + Gateway reference - the Gateway to attach to
+
true
annotationsmap[string]string + Additional annotations for the HTTPRoute
+
+ Default: map[]
+
false
enabledboolean + Enable HTTPRoute creation (default: false)
+
+ Default: false
+
false
hostnames[]string + Hostnames for the HTTPRoute
+
+ Default: []
+
false
labelsmap[string]string + Additional labels for the HTTPRoute
+
+ Default: map[]
+
false
pathstring + Path match for the route (default: /)
+
+ Default: /
+
false
pathTypestring + Path match type: Exact, PathPrefix, or RegularExpression (default: PathPrefix)
+
+ Default: PathPrefix
+
false
+ + +### Sonarr.spec.httpRoute.gatewayRef +[↩ Parent](#sonarrspechttproute) + + + +Gateway reference - the Gateway to attach to + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring + Name of the Gateway
+
true
namespacestring + Namespace of the Gateway (optional, defaults to same namespace as HTTPRoute)
+
false
sectionNamestring + Section name within the Gateway (optional)
+
false
+ + +### Sonarr.spec.ingress +[↩ Parent](#sonarrspec) + + + +Ingress configuration (optional) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
hoststring + Hostname for the ingress
+
true
annotationsmap[string]string + Ingress annotations
+
+ Default: map[]
+
false
enabledboolean + Enable ingress (default: false)
+
+ Default: false
+
false
ingressClassNamestring + Ingress class name
+
false
pathstring + Path for the ingress (default: /)
+
+ Default: /
+
false
pathTypestring + Path type (default: Prefix)
+
+ Default: Prefix
+
false
tlsobject + TLS configuration
+
false
+ + +### Sonarr.spec.ingress.tls +[↩ Parent](#sonarrspecingress) + + + +TLS configuration + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
secretNamestring + Secret name containing TLS certificate
+
true
hosts[]string + Hosts covered by the TLS certificate
+
+ Default: []
+
false
+ + +### Sonarr.spec.initContainer +[↩ Parent](#sonarrspec) + + + +Init container configuration (for custom init logic) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
args[]string + Arguments for the command
+
+ Default: []
+
false
command[]string + Command to run in init container
+
+ Default: []
+
false
env[]object + Environment variables for init container
+
+ Default: []
+
false
imagestring + Image for init container (default: busybox:latest)
+
+ Default: busybox:latest
+
false
+ + +### Sonarr.spec.initContainer.env[index] +[↩ Parent](#sonarrspecinitcontainer) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring + Name of the environment variable
+
true
valuestring + Value of the environment variable
+
false
valueFromobject + Reference to a secret or configmap
+
false
+ + +### Sonarr.spec.initContainer.env[index].valueFrom +[↩ Parent](#sonarrspecinitcontainerenvindex) + + + +Reference to a secret or configmap + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
configMapKeyRefobject + ConfigMap key reference
+
false
secretKeyRefobject + Secret key reference
+
false
+ + +### Sonarr.spec.initContainer.env[index].valueFrom.configMapKeyRef +[↩ Parent](#sonarrspecinitcontainerenvindexvaluefrom) + + + +ConfigMap key reference + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
keystring + Key in the configmap
+
true
namestring + Name of the configmap
+
true
+ + +### Sonarr.spec.initContainer.env[index].valueFrom.secretKeyRef +[↩ Parent](#sonarrspecinitcontainerenvindexvaluefrom) + + + +Secret key reference + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
keystring + Key in the secret
+
true
namestring + Name of the secret
+
true
+ + +### Sonarr.spec.resources +[↩ Parent](#sonarrspec) + + + +Resource requirements + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
limitsmap[string]string + Resource limits
+
+ Default: map[]
+
false
requestsmap[string]string + Resource requests
+
+ Default: map[]
+
false
+ + +### Sonarr.spec.securityContext +[↩ Parent](#sonarrspec) + + + +Pod security context + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
fsGroupinteger +
+
+ Format: int64
+
false
runAsGroupinteger +
+
+ Format: int64
+
false
runAsNonRootboolean +
+
false
runAsUserinteger +
+
+ Format: int64
+
false
+ + +### Sonarr.spec.service +[↩ Parent](#sonarrspec) + + + +Service configuration + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
annotationsmap[string]string + Service annotations
+
+ Default: map[]
+
false
containerPortinteger + Container port - the port Sonarr listens on inside the container (default: 8989)
+
+ Format: int32
+ Default: 8989
+
false
nodePortinteger + Node port (only for NodePort type)
+
+ Format: int32
+
false
portinteger + Service port (default: 8989)
+
+ Format: int32
+ Default: 8989
+
false
serviceTypestring + Service type (default: ClusterIP)
+
+ Default: ClusterIP
+
false
+ + +### Sonarr.spec.storage +[↩ Parent](#sonarrspec) + + + +Storage configuration + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
accessModes[]string + Access modes (default: ReadWriteOnce)
+
+ Default: [ReadWriteOnce]
+
false
existingClaimstring + Existing PVC to use (optional)
+
false
sizestring + Size of the config PVC (default: 1Gi)
+
+ Default: 1Gi
+
false
storageClassstring + Storage class for the PVC
+
false
+ + +### Sonarr.spec.tolerations[index] +[↩ Parent](#sonarrspec) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
effectstring +
+
false
keystring +
+
false
operatorstring +
+
false
tolerationSecondsinteger +
+
+ Format: int64
+
false
valuestring +
+
false
+ + +### Sonarr.spec.volumeMounts[index] +[↩ Parent](#sonarrspec) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
mountPathstring + Mount path inside the container
+
true
namestring + Name of the volume
+
true
readOnlyboolean + Read only flag
+
+ Default: false
+
false
subPathstring + Sub path (optional)
+
false
+ + +### Sonarr.spec.volumes[index] +[↩ Parent](#sonarrspec) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring + Name of the volume
+
true
configMapobject + ConfigMap volume
+
false
emptyDirobject + Empty dir volume
+
false
hostPathobject + HostPath volume
+
false
nfsobject + NFS volume
+
false
persistentVolumeClaimobject + PVC claim
+
false
+ + +### Sonarr.spec.volumes[index].configMap +[↩ Parent](#sonarrspecvolumesindex) + + + +ConfigMap volume + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring +
+
true
items[]object +
+
+ Default: []
+
false
+ + +### Sonarr.spec.volumes[index].configMap.items[index] +[↩ Parent](#sonarrspecvolumesindexconfigmap) + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
keystring +
+
true
pathstring +
+
true
+ + +### Sonarr.spec.volumes[index].emptyDir +[↩ Parent](#sonarrspecvolumesindex) + + + +Empty dir volume + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
mediumstring +
+
false
sizeLimitstring +
+
false
+ + +### Sonarr.spec.volumes[index].hostPath +[↩ Parent](#sonarrspecvolumesindex) + + + +HostPath volume + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
pathstring +
+
true
typestring +
+
false
+ + +### Sonarr.spec.volumes[index].nfs +[↩ Parent](#sonarrspecvolumesindex) + + + +NFS volume + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
pathstring +
+
true
serverstring +
+
true
readOnlyboolean +
+
+ Default: false
+
false
+ + +### Sonarr.spec.volumes[index].persistentVolumeClaim +[↩ Parent](#sonarrspecvolumesindex) + + + +PVC claim + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
claimNamestring +
+
true
readOnlyboolean +
+
+ Default: false
+
false
+ + +### Sonarr.status +[↩ Parent](#sonarr) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
apiKeySecretstring + API key (stored in secret)
+
false
conditions[]object + Current conditions
+
+ Default: []
+
false
observedGenerationinteger + Observed generation
+
+ Format: int64
+ Default: 0
+
false
readyReplicasinteger + Number of ready replicas
+
+ Format: int32
+ Default: 0
+
false
urlstring + URL to access Sonarr
+
false
versionstring + Sonarr version
+
false
+ + +### Sonarr.status.conditions[index] +[↩ Parent](#sonarrstatus) + + + +Condition contains details for one aspect of the current state of this API Resource. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
lastTransitionTimestring + lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+
+ Format: date-time
+
true
messagestring + message is a human readable message indicating details about the transition. This may be an empty string.
+
true
reasonstring + reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty.
+
true
statusstring + status of the condition, one of True, False, Unknown.
+
true
typestring + type of condition in CamelCase or in foo.example.com/CamelCase.
+
true
observedGenerationinteger + observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance.
+
+ Format: int64
+
false
+ +## SonarrTag +[↩ Parent](#devopsarriov1alpha1 ) + + + + + + +Auto-generated derived type for SonarrTagSpec via `CustomResource` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
apiVersionstringdevopsarr.io/v1alpha1true
kindstringSonarrTagtrue
metadataobjectRefer to the Kubernetes API documentation for the fields of the `metadata` field.true
specobject + SonarrTag represents a tag in Sonarr +Tags are used to organize and filter series, profiles, and other resources
+
true
statusobject +
+
false
+ + +### SonarrTag.spec +[↩ Parent](#sonarrtag) + + + +SonarrTag represents a tag in Sonarr +Tags are used to organize and filter series, profiles, and other resources + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
labelstring + Tag label (must be lowercase)
+
true
sonarrInstanceRefobject + Reference to the SonarrInstance
+
true
+ + +### SonarrTag.spec.sonarrInstanceRef +[↩ Parent](#sonarrtagspec) + + + +Reference to the SonarrInstance + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
namestring + Name of the SonarrInstance resource
+
+ Default:
+
false
namespacestring + Namespace of the SonarrInstance (optional, defaults to same namespace)
+
false
+ + +### SonarrTag.status +[↩ Parent](#sonarrtag) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
conditions[]object + Current conditions
+
+ Default: []
+
false
idinteger + Sonarr Tag ID
+
+ Format: int32
+
false
observedGenerationinteger + Observed generation
+
+ Format: int64
+ Default: 0
+
false
+ + +### SonarrTag.status.conditions[index] +[↩ Parent](#sonarrtagstatus) + + + +Condition contains details for one aspect of the current state of this API Resource. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
lastTransitionTimestring + lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+
+ Format: date-time
+
true
messagestring + message is a human readable message indicating details about the transition. This may be an empty string.
+
true
reasonstring + reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty.
+
true
statusstring + status of the condition, one of True, False, Unknown.
+
true
typestring + type of condition in CamelCase or in foo.example.com/CamelCase.
+
true
observedGenerationinteger + observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance.
+
+ Format: int64
+
false
diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..6cfd405 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "separate-pull-requests": false, + "plugins": [ + { + "type": "linked-versions", + "groupName": "sonarr-operator", + "components": [ + "sonarr-operator", + "sonarr-operator-chart" + ] + } + ], + "packages": { + ".": { + "release-type": "rust", + "component": "sonarr-operator", + "package-name": "sonarr-operator", + "include-component-in-tag": false, + "extra-files": [ + "deploy/deployment.yaml" + ] + }, + "charts/sonarr-operator": { + "release-type": "helm", + "component": "sonarr-operator-chart", + "package-name": "sonarr-operator-chart", + "include-component-in-tag": true, + "extra-files": [ + "Chart.yaml" + ] + } + } +} diff --git a/scripts/verify-release.sh b/scripts/verify-release.sh new file mode 100755 index 0000000..f8db23c --- /dev/null +++ b/scripts/verify-release.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Verify a published release of the sonarr-operator: +# - operator image exists on GHCR for the given version +# - chart exists on GHCR +# - cosign signatures verify (keyless, GitHub OIDC) +# - chart renders cleanly with helm template +# +# Usage: +# scripts/verify-release.sh v0.1.0 +# +# Requirements: docker, cosign, helm, jq (optional) +# For private packages, run `docker login ghcr.io` and +# `helm registry login ghcr.io` beforehand. + +set -euo pipefail + +VERSION="${1:?usage: $0 (e.g. v0.1.0)}" +VERSION_NOPREFIX="${VERSION#v}" + +OWNER="${OWNER:-devopsarr}" +REPO="${REPO:-k8s-operator-sonarr}" +IMAGE="ghcr.io/${OWNER}/${REPO}:${VERSION}" +CHART_REF="ghcr.io/${OWNER}/charts/sonarr-operator" +CHART="oci://${CHART_REF}" +EXPECTED_IDENTITY_REGEX="^https://github.com/${OWNER}/${REPO}/" +OIDC_ISSUER="https://token.actions.githubusercontent.com" + +step() { printf "\n==> %s\n" "$*"; } +ok() { printf " ✓ %s\n" "$*"; } +fail() { printf " ✗ %s\n" "$*" >&2; exit 1; } + +require() { + command -v "$1" >/dev/null 2>&1 || fail "missing required tool: $1" +} + +require docker +require cosign +require helm + +step "Pulling operator image: ${IMAGE}" +docker pull "${IMAGE}" >/dev/null && ok "image pulled" + +step "Verifying image signature (keyless cosign)" +cosign verify "${IMAGE}" \ + --certificate-identity-regexp "${EXPECTED_IDENTITY_REGEX}" \ + --certificate-oidc-issuer "${OIDC_ISSUER}" >/dev/null && ok "image signature verified" + +step "Pulling chart: ${CHART} --version ${VERSION_NOPREFIX}" +WORKDIR="$(mktemp -d)" +trap 'rm -rf "${WORKDIR}"' EXIT +helm pull "${CHART}" --version "${VERSION_NOPREFIX}" --destination "${WORKDIR}" >/dev/null && ok "chart pulled" + +CHART_TGZ="$(ls "${WORKDIR}"/sonarr-operator-*.tgz)" +ok "chart artifact: $(basename "${CHART_TGZ}")" + +step "Verifying chart signature (keyless cosign)" +cosign verify "${CHART_REF}:${VERSION_NOPREFIX}" \ + --certificate-identity-regexp "${EXPECTED_IDENTITY_REGEX}" \ + --certificate-oidc-issuer "${OIDC_ISSUER}" >/dev/null && ok "chart signature verified" + +step "helm template (defaults)" +helm template verify "${CHART_TGZ}" --namespace sonarr-operator-system >/dev/null && ok "chart renders" + +step "helm template (CRDs gated out)" +helm template verify "${CHART_TGZ}" --namespace sonarr-operator-system \ + --set crds.install=false >/dev/null && ok "chart renders without CRDs" + +step "All checks passed for ${VERSION}" diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000..9bf32cf --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,73 @@ +//! Sonarr API client factory using the sonarr crate +//! +//! This module provides a factory for creating and caching sonarr Configuration +//! objects for communicating with Sonarr instances. + +use sonarr::apis::configuration::{ApiKey, Configuration}; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::RwLock; + +/// Factory for creating Sonarr API configurations +/// Caches configurations by instance name/namespace to reuse connections +pub struct SonarrClientFactory { + configs: RwLock>>, +} + +impl SonarrClientFactory { + pub fn new() -> Self { + Self { + configs: RwLock::new(HashMap::new()), + } + } + + /// Get or create a Configuration for the given Sonarr instance + pub async fn get_config( + &self, + url: &str, + api_key: &str, + instance_key: &str, + ) -> Arc { + // Check if we have a cached config + { + let configs = self.configs.read().await; + if let Some(config) = configs.get(instance_key) { + return config.clone(); + } + } + + // Create a new configuration + let mut config = Configuration::new(); + config.base_path = url.trim_end_matches('/').to_string(); + config.api_key = Some(ApiKey { + prefix: None, + key: api_key.to_string(), + }); + + let config = Arc::new(config); + + // Cache it + { + let mut configs = self.configs.write().await; + configs.insert(instance_key.to_string(), config.clone()); + } + + config + } + + /// Remove a configuration from the cache (e.g., when credentials change) + pub async fn invalidate(&self, instance_key: &str) { + let mut configs = self.configs.write().await; + configs.remove(instance_key); + } +} + +impl Default for SonarrClientFactory { + fn default() -> Self { + Self::new() + } +} + +// Re-export sonarr types that controllers will need +pub use sonarr::apis; +pub use sonarr::models; diff --git a/src/bin/crdgen.rs b/src/bin/crdgen.rs new file mode 100644 index 0000000..c831f12 --- /dev/null +++ b/src/bin/crdgen.rs @@ -0,0 +1,193 @@ +//! CRD Generator Binary +//! +//! Generates the Kubernetes Custom Resource Definitions for the Sonarr operator. +//! +//! Usage: +//! cargo run --bin crdgen # All CRDs to stdout (combined) +//! cargo run --bin crdgen -- --split # Plain CRD files (one per CRD) +//! cargo run --bin crdgen -- --split --helm # Helm-templated CRDs (gated + keep) +//! cargo run --bin crdgen -- --single # Single CRD to stdout +//! +//! Examples: +//! cargo run --bin crdgen > crds.yaml +//! cargo run --bin crdgen -- --split charts/sonarr-operator/templates/crds --helm +//! cargo run --bin crdgen -- --single SonarrTag > tag-crd.yaml + +use kube::CustomResourceExt; +use std::fs; +use std::path::Path; + +use sonarr_operator::crds; + +/// CRD definition with metadata for file generation +struct CrdInfo { + name: &'static str, + filename: &'static str, + crd: k8s_openapi::apiextensions_apiserver::pkg::apis::apiextensions::v1::CustomResourceDefinition, +} + +/// Macro to generate CRD entries from type names. +/// This ensures we can't forget to add new CRDs - just add the type name once. +macro_rules! register_crds { + ($($crd_type:ident => $filename:literal),* $(,)?) => { + vec![ + $( + CrdInfo { + name: stringify!($crd_type), + filename: $filename, + crd: crds::$crd_type::crd(), + }, + )* + ] + }; +} + +fn get_all_crds() -> Vec { + // To add a new CRD, simply add a new line here: TypeName => "filename.yaml" + register_crds!( + Sonarr => "sonarr.yaml", + SonarrAutoTag => "autotag.yaml", + SonarrCustomFormat => "customformat.yaml", + SonarrDelayProfile => "delayprofile.yaml", + SonarrDownloadClient => "downloadclient.yaml", + SonarrDownloadClientConfig => "downloadclientconfig.yaml", + SonarrImportList => "importlist.yaml", + SonarrIndexer => "indexer.yaml", + SonarrIndexerConfig => "indexerconfig.yaml", + SonarrLanguageProfile => "languageprofile.yaml", + SonarrMediaManagementConfig => "mediamanagementconfig.yaml", + SonarrMetadata => "metadata.yaml", + SonarrNamingConfig => "namingconfig.yaml", + SonarrNotification => "notification.yaml", + SonarrQualityDefinition => "qualitydefinition.yaml", + SonarrQualityProfile => "qualityprofile.yaml", + SonarrRootFolder => "rootfolder.yaml", + SonarrSeries => "series.yaml", + SonarrTag => "tag.yaml", + ) +} + +/// Inject Helm templating into a serialised CRD YAML document so it can be +/// rendered by the chart. The wrapper: +/// - guards the whole document on `.Values.crds.install` +/// - conditionally emits `helm.sh/resource-policy: keep` based on `.Values.crds.keep` +/// - merges user-supplied `.Values.crds.annotations` and `.Values.crds.additionalLabels` +/// +/// The annotation injection is purely string-level (no extra deps): CRDs always +/// serialise with a top-level `metadata:` block produced by `serde_yaml`, so we +/// anchor on that. +fn wrap_for_helm(yaml: &str) -> String { + const ANCHOR: &str = "metadata:\n"; + + // Helm-templated metadata stanza to be injected after `metadata:`. It: + // - emits an `annotations:` block only when `.Values.crds.keep` or + // `.Values.crds.annotations` are present (using `or`) + // - merges user-provided annotations and labels via `toYaml` so callers + // can customize per-chart extra metadata + const METADATA_INJECTION: &str = r#" {{- if or .Values.crds.keep .Values.crds.annotations }} + annotations: + {{- if .Values.crds.keep }} + helm.sh/resource-policy: keep + {{- end }} + {{- with .Values.crds.annotations }} +{{ toYaml . | indent 4 }} + {{- end }} + {{- end }} + {{- with .Values.crds.additionalLabels }} + labels: +{{ toYaml . | indent 4 }} + {{- end }} +"#; + + let injected = match yaml.find(ANCHOR) { + Some(idx) => { + let insert_at = idx + ANCHOR.len(); + let mut s = String::with_capacity(yaml.len() + METADATA_INJECTION.len()); + s.push_str(&yaml[..insert_at]); + s.push_str(METADATA_INJECTION); + s.push_str(&yaml[insert_at..]); + s + } + None => { + eprintln!( + "warning: could not find `metadata:` anchor; emitting CRD without helm metadata injection" + ); + yaml.to_string() + } + }; + + let mut out = String::with_capacity(injected.len() + 96); + out.push_str("{{- if .Values.crds.install }}\n"); + out.push_str(&injected); + if !injected.ends_with('\n') { + out.push('\n'); + } + out.push_str("{{- end }}\n"); + out +} + +fn main() { + let args: Vec = std::env::args().collect(); + + // Check for --split flag (write individual files) + if args.len() > 2 && args[1] == "--split" { + let output_dir = &args[2]; + let helm_mode = args.iter().any(|a| a == "--helm"); + let path = Path::new(output_dir); + + // Create directory if it doesn't exist + if let Err(e) = fs::create_dir_all(path) { + eprintln!("Failed to create directory {}: {}", output_dir, e); + std::process::exit(1); + } + + let crds = get_all_crds(); + for crd_info in crds { + let file_path = path.join(crd_info.filename); + let yaml = serde_yaml::to_string(&crd_info.crd).unwrap(); + let contents = if helm_mode { + wrap_for_helm(&yaml) + } else { + yaml + }; + + if let Err(e) = fs::write(&file_path, contents) { + eprintln!("Failed to write {}: {}", file_path.display(), e); + std::process::exit(1); + } + eprintln!("Generated: {}", file_path.display()); + } + + eprintln!( + "All CRDs generated in {}/{}", + output_dir, + if helm_mode { " (helm mode)" } else { "" } + ); + return; + } + + // Check if user wants a single CRD + if args.len() > 2 && args[1] == "--single" { + let crd_name = &args[2]; + let crds = get_all_crds(); + + if let Some(crd_info) = crds.iter().find(|c| c.name == crd_name) { + print!("{}", serde_yaml::to_string(&crd_info.crd).unwrap()); + return; + } + + eprintln!("Unknown CRD: {}", crd_name); + let available: Vec<_> = crds.iter().map(|c| c.name).collect(); + eprintln!("Available CRDs: {}", available.join(", ")); + std::process::exit(1); + } + + // Generate all CRDs to stdout (combined format) + let crds = get_all_crds(); + for (i, crd_info) in crds.iter().enumerate() { + if i > 0 { + println!("---"); + } + print!("{}", serde_yaml::to_string(&crd_info.crd).unwrap()); + } +} diff --git a/src/controllers/auto_tag.rs b/src/controllers/auto_tag.rs new file mode 100644 index 0000000..d45ffb9 --- /dev/null +++ b/src/controllers/auto_tag.rs @@ -0,0 +1,177 @@ +use std::sync::Arc; + +use kube::api::{Api, Patch, PatchParams}; +use kube::runtime::controller::Action; +use kube::{Client, ResourceExt}; +use tracing::info; + +use sonarr::apis::auto_tagging_api; +use sonarr::models::{AutoTaggingResource, AutoTaggingSpecificationSchema, Field}; + +use crate::Context; +use crate::crds::auto_tag::{AutoTagImplementation, AutoTagSpecification}; +use crate::crds::{SonarrAutoTag, SonarrAutoTagStatus}; +use crate::error::{Error, Result}; + +use super::tag::get_sonarr_config; +use super::traits::{REQUEUE_DURATION, reconcile_with_finalizer, run_controller}; +use super::{ready_condition, update_conditions}; + +/// Start the SonarrAutoTag controller +pub async fn run(client: Client, context: Arc) { + run_controller::(client, context, "SonarrAutoTag", reconcile).await; +} + +async fn reconcile(obj: Arc, ctx: Arc) -> Result { + reconcile_with_finalizer(obj, ctx, reconcile_apply, reconcile_cleanup).await +} + +async fn reconcile_apply(auto_tag: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = auto_tag + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + let name = auto_tag.name_any(); + + info!("Reconciling SonarrAutoTag: {}/{}", namespace, name); + + let config = + get_sonarr_config(&ctx, client, &namespace, &auto_tag.spec.sonarr_instance_ref).await?; + + // Build auto-tagging resource + let mut resource = AutoTaggingResource::new(); + resource.name = Some(Some(auto_tag.spec.name.clone())); + resource.remove_tags_automatically = Some(auto_tag.spec.remove_tags_automatically); + resource.tags = Some(Some(auto_tag.spec.tags.clone())); + + // Convert specifications + let specs: Vec = auto_tag + .spec + .specifications + .iter() + .map(convert_specification) + .collect(); + resource.specifications = Some(Some(specs)); + + let sonarr_auto_tag = if let Some(id) = auto_tag.status.as_ref().and_then(|s| s.id) { + resource.id = Some(id); + match auto_tagging_api::update_auto_tagging( + &config, + &id.to_string(), + Some(resource.clone()), + ) + .await + { + Ok(a) => a, + Err(_) => { + resource.id = None; + auto_tagging_api::create_auto_tagging(&config, Some(resource)).await? + } + } + } else { + let existing = auto_tagging_api::list_auto_tagging(&config).await?; + if let Some(existing_item) = existing + .iter() + .find(|a| a.name.as_ref().and_then(|n| n.as_ref()) == Some(&auto_tag.spec.name)) + { + existing_item.clone() + } else { + auto_tagging_api::create_auto_tagging(&config, Some(resource)).await? + } + }; + + // Update status + let api: Api = Api::namespaced(client.clone(), &namespace); + let mut conditions = auto_tag + .status + .as_ref() + .map(|s| s.conditions.clone()) + .unwrap_or_default(); + update_conditions( + &mut conditions, + ready_condition(true, "Synced", "Auto tag synchronized with Sonarr"), + ); + + let status = SonarrAutoTagStatus { + conditions, + id: sonarr_auto_tag.id, + observed_generation: auto_tag.metadata.generation.unwrap_or(0), + }; + + let status_patch = serde_json::json!({ "status": status }); + api.patch_status(&name, &PatchParams::default(), &Patch::Merge(&status_patch)) + .await?; + + Ok(Action::requeue(REQUEUE_DURATION)) +} + +fn convert_specification(spec: &AutoTagSpecification) -> AutoTaggingSpecificationSchema { + let mut schema = AutoTaggingSpecificationSchema::new(); + schema.name = Some(Some(spec.name.clone())); + schema.implementation = Some(Some( + get_implementation_name(&spec.implementation).to_string(), + )); + schema.negate = Some(spec.negate); + schema.required = Some(spec.required); + + // Map fields to Sonarr API Field objects + let mut fields = Vec::new(); + if let Some(ref value) = spec.fields.value { + let mut field = Field::new(); + field.name = Some(Some("value".to_string())); + field.value = Some(Some(serde_json::Value::String(value.clone()))); + fields.push(field); + } + if let Some(min) = spec.fields.min { + let mut field = Field::new(); + field.name = Some(Some("min".to_string())); + field.value = Some(Some(serde_json::json!(min))); + fields.push(field); + } + if let Some(max) = spec.fields.max { + let mut field = Field::new(); + field.name = Some(Some("max".to_string())); + field.value = Some(Some(serde_json::json!(max))); + fields.push(field); + } + if !fields.is_empty() { + schema.fields = Some(Some(fields)); + } + + schema +} + +fn get_implementation_name(impl_type: &AutoTagImplementation) -> &'static str { + match impl_type { + AutoTagImplementation::RootFolderSpecification => "RootFolderSpecification", + AutoTagImplementation::GenreSpecification => "GenreSpecification", + AutoTagImplementation::YearSpecification => "YearSpecification", + AutoTagImplementation::SeriesTypeSpecification => "SeriesTypeSpecification", + AutoTagImplementation::QualityProfileSpecification => "QualityProfileSpecification", + AutoTagImplementation::NetworkSpecification => "NetworkSpecification", + AutoTagImplementation::OriginalLanguageSpecification => "OriginalLanguageSpecification", + AutoTagImplementation::TagSpecification => "TagSpecification", + } +} + +async fn reconcile_cleanup(auto_tag: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = auto_tag + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + + info!( + "Cleaning up SonarrAutoTag: {}/{}", + namespace, + auto_tag.name_any() + ); + + if let Some(id) = auto_tag.status.as_ref().and_then(|s| s.id) + && let Ok(config) = + get_sonarr_config(&ctx, client, &namespace, &auto_tag.spec.sonarr_instance_ref).await + { + let _ = auto_tagging_api::delete_auto_tagging(&config, id).await; + } + + Ok(Action::await_change()) +} diff --git a/src/controllers/custom_format.rs b/src/controllers/custom_format.rs new file mode 100644 index 0000000..ef841d9 --- /dev/null +++ b/src/controllers/custom_format.rs @@ -0,0 +1,178 @@ +use std::sync::Arc; + +use kube::api::{Api, Patch, PatchParams}; +use kube::runtime::controller::Action; +use kube::{Client, ResourceExt}; +use tracing::info; + +use sonarr::apis::custom_format_api; +use sonarr::models::{CustomFormatResource, CustomFormatSpecificationSchema, Field}; + +use crate::Context; +use crate::crds::custom_format::{CustomFormatImplementation, CustomFormatSpecification}; +use crate::crds::{SonarrCustomFormat, SonarrCustomFormatStatus}; +use crate::error::{Error, Result}; + +use super::tag::get_sonarr_config; +use super::traits::{REQUEUE_DURATION, reconcile_with_finalizer, run_controller}; +use super::{ready_condition, update_conditions}; + +/// Start the SonarrCustomFormat controller +pub async fn run(client: Client, context: Arc) { + run_controller::(client, context, "SonarrCustomFormat", reconcile) + .await; +} + +async fn reconcile(obj: Arc, ctx: Arc) -> Result { + reconcile_with_finalizer(obj, ctx, reconcile_apply, reconcile_cleanup).await +} + +async fn reconcile_apply(cf: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = cf + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + let name = cf.name_any(); + + info!("Reconciling SonarrCustomFormat: {}/{}", namespace, name); + + let config = get_sonarr_config(&ctx, client, &namespace, &cf.spec.sonarr_instance_ref).await?; + + // Build custom format resource + let mut resource = CustomFormatResource::new(); + resource.name = Some(Some(cf.spec.name.clone())); + resource.include_custom_format_when_renaming = + Some(Some(cf.spec.include_custom_format_when_renaming)); + + // Convert specifications + let specs: Vec = cf + .spec + .specifications + .iter() + .map(convert_specification) + .collect(); + resource.specifications = Some(Some(specs)); + + let sonarr_cf = if let Some(id) = cf.status.as_ref().and_then(|s| s.id) { + resource.id = Some(id); + match custom_format_api::update_custom_format( + &config, + &id.to_string(), + Some(resource.clone()), + ) + .await + { + Ok(c) => c, + Err(_) => { + resource.id = None; + custom_format_api::create_custom_format(&config, Some(resource)).await? + } + } + } else { + let existing = custom_format_api::list_custom_format(&config).await?; + if let Some(existing_item) = existing + .iter() + .find(|c| c.name.as_ref().and_then(|n| n.as_ref()) == Some(&cf.spec.name)) + { + existing_item.clone() + } else { + custom_format_api::create_custom_format(&config, Some(resource)).await? + } + }; + + // Update status + let api: Api = Api::namespaced(client.clone(), &namespace); + let mut conditions = cf + .status + .as_ref() + .map(|s| s.conditions.clone()) + .unwrap_or_default(); + update_conditions( + &mut conditions, + ready_condition(true, "Synced", "Custom format synchronized with Sonarr"), + ); + + let status = SonarrCustomFormatStatus { + conditions, + id: sonarr_cf.id, + observed_generation: cf.metadata.generation.unwrap_or(0), + }; + + let status_patch = serde_json::json!({ "status": status }); + api.patch_status(&name, &PatchParams::default(), &Patch::Merge(&status_patch)) + .await?; + + Ok(Action::requeue(REQUEUE_DURATION)) +} + +fn convert_specification(spec: &CustomFormatSpecification) -> CustomFormatSpecificationSchema { + let mut schema = CustomFormatSpecificationSchema::new(); + schema.name = Some(Some(spec.name.clone())); + schema.implementation = Some(Some( + get_implementation_name(&spec.implementation).to_string(), + )); + schema.negate = Some(spec.negate); + schema.required = Some(spec.required); + + // Map fields to Sonarr API Field objects + let mut fields = Vec::new(); + if let Some(ref value) = spec.fields.value { + let mut field = Field::new(); + field.name = Some(Some("value".to_string())); + field.value = Some(Some(serde_json::Value::String(value.clone()))); + fields.push(field); + } + if let Some(min) = spec.fields.min { + let mut field = Field::new(); + field.name = Some(Some("min".to_string())); + field.value = Some(Some(serde_json::json!(min))); + fields.push(field); + } + if let Some(max) = spec.fields.max { + let mut field = Field::new(); + field.name = Some(Some("max".to_string())); + field.value = Some(Some(serde_json::json!(max))); + fields.push(field); + } + if !fields.is_empty() { + schema.fields = Some(Some(fields)); + } + + schema +} + +fn get_implementation_name(impl_type: &CustomFormatImplementation) -> &'static str { + match impl_type { + CustomFormatImplementation::ReleaseTitleSpecification => "ReleaseTitleSpecification", + CustomFormatImplementation::SourceSpecification => "SourceSpecification", + CustomFormatImplementation::ResolutionSpecification => "ResolutionSpecification", + CustomFormatImplementation::QualityModifierSpecification => "QualityModifierSpecification", + CustomFormatImplementation::SizeSpecification => "SizeSpecification", + CustomFormatImplementation::IndexerFlagSpecification => "IndexerFlagSpecification", + CustomFormatImplementation::LanguageSpecification => "LanguageSpecification", + CustomFormatImplementation::ReleaseGroupSpecification => "ReleaseGroupSpecification", + CustomFormatImplementation::EditionSpecification => "EditionSpecification", + } +} + +async fn reconcile_cleanup(cf: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = cf + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + + info!( + "Cleaning up SonarrCustomFormat: {}/{}", + namespace, + cf.name_any() + ); + + if let Some(id) = cf.status.as_ref().and_then(|s| s.id) + && let Ok(config) = + get_sonarr_config(&ctx, client, &namespace, &cf.spec.sonarr_instance_ref).await + { + let _ = custom_format_api::delete_custom_format(&config, id).await; + } + + Ok(Action::await_change()) +} diff --git a/src/controllers/delay_profile.rs b/src/controllers/delay_profile.rs new file mode 100644 index 0000000..1de4b9e --- /dev/null +++ b/src/controllers/delay_profile.rs @@ -0,0 +1,129 @@ +use std::sync::Arc; + +use kube::api::{Api, Patch, PatchParams}; +use kube::runtime::controller::Action; +use kube::{Client, ResourceExt}; +use tracing::info; + +use sonarr::apis::delay_profile_api; +use sonarr::models::DelayProfileResource; + +use crate::Context; +use crate::crds::delay_profile::DownloadProtocol; +use crate::crds::{SonarrDelayProfile, SonarrDelayProfileStatus}; +use crate::error::{Error, Result}; + +use super::tag::get_sonarr_config; +use super::traits::{REQUEUE_DURATION, reconcile_with_finalizer, run_controller}; +use super::{ready_condition, update_conditions}; + +/// Start the SonarrDelayProfile controller +pub async fn run(client: Client, context: Arc) { + run_controller::(client, context, "SonarrDelayProfile", reconcile) + .await; +} + +async fn reconcile(obj: Arc, ctx: Arc) -> Result { + reconcile_with_finalizer(obj, ctx, reconcile_apply, reconcile_cleanup).await +} + +async fn reconcile_apply(profile: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = profile + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + let name = profile.name_any(); + + info!("Reconciling SonarrDelayProfile: {}/{}", namespace, name); + + let config = + get_sonarr_config(&ctx, client, &namespace, &profile.spec.sonarr_instance_ref).await?; + + // Build delay profile resource + let mut resource = DelayProfileResource::new(); + resource.enable_usenet = Some(profile.spec.enable_usenet); + resource.enable_torrent = Some(profile.spec.enable_torrent); + resource.preferred_protocol = Some(convert_protocol(&profile.spec.preferred_protocol)); + resource.usenet_delay = Some(profile.spec.usenet_delay); + resource.torrent_delay = Some(profile.spec.torrent_delay); + resource.bypass_if_highest_quality = Some(profile.spec.bypass_if_highest_quality); + resource.bypass_if_above_custom_format_score = + Some(profile.spec.bypass_if_above_custom_format_score); + resource.minimum_custom_format_score = Some(profile.spec.minimum_custom_format_score); + resource.order = Some(profile.spec.order); + resource.tags = Some(Some(profile.spec.tags.clone())); + + let sonarr_profile = if let Some(id) = profile.status.as_ref().and_then(|s| s.id) { + resource.id = Some(id); + match delay_profile_api::update_delay_profile( + &config, + &id.to_string(), + Some(resource.clone()), + ) + .await + { + Ok(p) => p, + Err(_) => { + resource.id = None; + delay_profile_api::create_delay_profile(&config, Some(resource)).await? + } + } + } else { + // Delay profiles don't have unique names, so we always create new ones + // unless we have an ID stored + delay_profile_api::create_delay_profile(&config, Some(resource)).await? + }; + + // Update status + let api: Api = Api::namespaced(client.clone(), &namespace); + let mut conditions = profile + .status + .as_ref() + .map(|s| s.conditions.clone()) + .unwrap_or_default(); + update_conditions( + &mut conditions, + ready_condition(true, "Synced", "Delay profile synchronized with Sonarr"), + ); + + let status = SonarrDelayProfileStatus { + conditions, + id: sonarr_profile.id, + observed_generation: profile.metadata.generation.unwrap_or(0), + }; + + let status_patch = serde_json::json!({ "status": status }); + api.patch_status(&name, &PatchParams::default(), &Patch::Merge(&status_patch)) + .await?; + + Ok(Action::requeue(REQUEUE_DURATION)) +} + +fn convert_protocol(protocol: &DownloadProtocol) -> sonarr::models::DownloadProtocol { + match protocol { + DownloadProtocol::Usenet => sonarr::models::DownloadProtocol::Usenet, + DownloadProtocol::Torrent => sonarr::models::DownloadProtocol::Torrent, + } +} + +async fn reconcile_cleanup(profile: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = profile + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + + info!( + "Cleaning up SonarrDelayProfile: {}/{}", + namespace, + profile.name_any() + ); + + if let Some(id) = profile.status.as_ref().and_then(|s| s.id) + && let Ok(config) = + get_sonarr_config(&ctx, client, &namespace, &profile.spec.sonarr_instance_ref).await + { + let _ = delay_profile_api::delete_delay_profile(&config, id).await; + } + + Ok(Action::await_change()) +} diff --git a/src/controllers/download_client.rs b/src/controllers/download_client.rs new file mode 100644 index 0000000..278b496 --- /dev/null +++ b/src/controllers/download_client.rs @@ -0,0 +1,156 @@ +use std::sync::Arc; + +use kube::api::{Api, Patch, PatchParams}; +use kube::runtime::controller::Action; +use kube::{Client, ResourceExt}; +use tracing::info; + +use sonarr::apis::download_client_api; +use sonarr::models::DownloadClientResource; + +use crate::Context; +use crate::crds::download_client::DownloadClientType; +use crate::crds::{SonarrDownloadClient, SonarrDownloadClientStatus}; +use crate::error::{Error, Result}; + +use super::tag::get_sonarr_config; +use super::traits::{REQUEUE_DURATION, reconcile_with_finalizer, run_controller}; +use super::{ready_condition, update_conditions}; + +/// Start the SonarrDownloadClient controller +pub async fn run(client: Client, context: Arc) { + run_controller::( + client, + context, + "SonarrDownloadClient", + reconcile, + ) + .await; +} + +async fn reconcile(obj: Arc, ctx: Arc) -> Result { + reconcile_with_finalizer(obj, ctx, reconcile_apply, reconcile_cleanup).await +} + +async fn reconcile_apply(dc: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = dc + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + let name = dc.name_any(); + + info!("Reconciling SonarrDownloadClient: {}/{}", namespace, name); + + let config = get_sonarr_config(&ctx, client, &namespace, &dc.spec.sonarr_instance_ref).await?; + + // Build download client resource + let mut dc_resource = DownloadClientResource::new(); + dc_resource.name = Some(Some(dc.spec.name.clone())); + dc_resource.implementation = Some(Some( + get_implementation_name(&dc.spec.download_client_type).to_string(), + )); + dc_resource.enable = Some(dc.spec.enable); + dc_resource.priority = Some(dc.spec.priority); + dc_resource.remove_completed_downloads = Some(dc.spec.remove_completed_downloads); + dc_resource.remove_failed_downloads = Some(dc.spec.remove_failed_downloads); + dc_resource.tags = Some(Some(dc.spec.tags.clone())); + + let sonarr_dc = if let Some(id) = dc.status.as_ref().and_then(|s| s.id) { + dc_resource.id = Some(id); + match download_client_api::update_download_client( + &config, + id, + Some(false), + Some(dc_resource.clone()), + ) + .await + { + Ok(d) => d, + Err(_) => { + dc_resource.id = None; + download_client_api::create_download_client(&config, Some(false), Some(dc_resource)) + .await? + } + } + } else { + let existing = download_client_api::list_download_client(&config).await?; + if let Some(existing_dc) = existing + .iter() + .find(|d| d.name.as_ref().and_then(|n| n.as_ref()) == Some(&dc.spec.name)) + { + existing_dc.clone() + } else { + download_client_api::create_download_client(&config, Some(false), Some(dc_resource)) + .await? + } + }; + + // Update status + let clients_api: Api = Api::namespaced(client.clone(), &namespace); + let mut conditions = dc + .status + .as_ref() + .map(|s| s.conditions.clone()) + .unwrap_or_default(); + update_conditions( + &mut conditions, + ready_condition(true, "Synced", "Download client synchronized with Sonarr"), + ); + + let status = SonarrDownloadClientStatus { + conditions, + id: sonarr_dc.id, + observed_generation: dc.metadata.generation.unwrap_or(0), + }; + + let status_patch = serde_json::json!({ "status": status }); + clients_api + .patch_status(&name, &PatchParams::default(), &Patch::Merge(&status_patch)) + .await?; + + Ok(Action::requeue(REQUEUE_DURATION)) +} + +fn get_implementation_name(dc_type: &DownloadClientType) -> &'static str { + match dc_type { + DownloadClientType::Aria2 => "Aria2", + DownloadClientType::Deluge => "Deluge", + DownloadClientType::Flood => "Flood", + DownloadClientType::Hadouken => "Hadouken", + DownloadClientType::NzbGet => "Nzbget", + DownloadClientType::NzbVortex => "NzbVortex", + DownloadClientType::Pneumatic => "Pneumatic", + DownloadClientType::QBittorrent => "QBittorrent", + DownloadClientType::RTorrent => "RTorrent", + DownloadClientType::SABnzbd => "Sabnzbd", + DownloadClientType::TorrentBlackhole => "TorrentBlackhole", + DownloadClientType::TorrentDownloadStation => "TorrentDownloadStation", + DownloadClientType::Transmission => "Transmission", + DownloadClientType::UsenetBlackhole => "UsenetBlackhole", + DownloadClientType::UsenetDownloadStation => "UsenetDownloadStation", + DownloadClientType::UTorrent => "UTorrent", + DownloadClientType::Vuze => "Vuze", + } +} + +async fn reconcile_cleanup(dc: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = dc + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + + info!( + "Cleaning up SonarrDownloadClient: {}/{}", + namespace, + dc.name_any() + ); + + if let Some(id) = dc.status.as_ref().and_then(|s| s.id) + && let Ok(config) = + get_sonarr_config(&ctx, client, &namespace, &dc.spec.sonarr_instance_ref).await + { + let _ = download_client_api::delete_download_client(&config, id).await; + } + + Ok(Action::await_change()) +} diff --git a/src/controllers/download_client_config.rs b/src/controllers/download_client_config.rs new file mode 100644 index 0000000..b379ffb --- /dev/null +++ b/src/controllers/download_client_config.rs @@ -0,0 +1,212 @@ +//! Controller for SonarrDownloadClientConfig +//! +//! This controller manages global download client configuration for Sonarr instances. +//! Only one SonarrDownloadClientConfig per Sonarr instance is allowed. + +use std::sync::Arc; + +use kube::api::{Api, ListParams, Patch, PatchParams}; +use kube::runtime::controller::Action; +use kube::{Client, ResourceExt}; +use tracing::{info, warn}; + +use sonarr::apis::download_client_config_api; +use sonarr::models::DownloadClientConfigResource; + +use crate::Context; +use crate::crds::download_client_config::{ + SonarrDownloadClientConfig, SonarrDownloadClientConfigStatus, +}; +use crate::error::{Error, Result}; + +use super::tag::get_sonarr_config; +use super::traits::{REQUEUE_DURATION, reconcile_with_finalizer, run_controller}; +use super::{ready_condition, update_conditions}; + +/// Start the SonarrDownloadClientConfig controller +pub async fn run(client: Client, context: Arc) { + run_controller::( + client, + context, + "SonarrDownloadClientConfig", + reconcile, + ) + .await; +} + +async fn reconcile(obj: Arc, ctx: Arc) -> Result { + reconcile_with_finalizer(obj, ctx, reconcile_apply, reconcile_cleanup).await +} + +/// Check if another SonarrDownloadClientConfig exists for the same Sonarr instance +async fn check_singleton( + client: &Client, + namespace: &str, + current_name: &str, + instance_ref_name: &str, + instance_ref_namespace: Option<&str>, +) -> Result> { + let api: Api = Api::namespaced(client.clone(), namespace); + let configs = api.list(&ListParams::default()).await?; + + for config in configs.items { + let config_name = config.name_any(); + if config_name == current_name { + continue; + } + + let ref_name = &config.spec.sonarr_instance_ref.name; + let ref_ns = config.spec.sonarr_instance_ref.namespace.as_deref(); + + if ref_name == instance_ref_name && ref_ns == instance_ref_namespace { + return Ok(Some(config_name)); + } + } + + Ok(None) +} + +async fn reconcile_apply( + config: Arc, + ctx: Arc, +) -> Result { + let client = &ctx.client; + let namespace = config + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + let name = config.name_any(); + + info!( + "Reconciling SonarrDownloadClientConfig: {}/{}", + namespace, name + ); + + // Check singleton constraint + if let Some(existing_name) = check_singleton( + client, + &namespace, + &name, + &config.spec.sonarr_instance_ref.name, + config.spec.sonarr_instance_ref.namespace.as_deref(), + ) + .await? + { + warn!( + "Another SonarrDownloadClientConfig '{}' already exists for Sonarr instance '{}'. Only one config per instance is allowed.", + existing_name, config.spec.sonarr_instance_ref.name + ); + + let api: Api = Api::namespaced(client.clone(), &namespace); + let mut conditions = config + .status + .as_ref() + .map(|s| s.conditions.clone()) + .unwrap_or_default(); + update_conditions( + &mut conditions, + ready_condition( + false, + "Conflict", + &format!( + "Another config '{}' already exists for this Sonarr instance", + existing_name + ), + ), + ); + + let status = SonarrDownloadClientConfigStatus { + conditions, + observed_generation: config.metadata.generation.unwrap_or(0), + }; + + api.patch_status( + &name, + &PatchParams::apply("sonarr-operator"), + &Patch::Merge(serde_json::json!({ "status": status })), + ) + .await?; + + return Ok(Action::requeue(REQUEUE_DURATION)); + } + + let sonarr_config = + get_sonarr_config(&ctx, client, &namespace, &config.spec.sonarr_instance_ref).await?; + + // Get existing config (there's only one, id=1) + let existing = download_client_config_api::get_download_client_config(&sonarr_config).await?; + + // Build update resource + let mut resource = DownloadClientConfigResource::new(); + resource.id = existing.id; + + resource.download_client_working_folders = config + .spec + .download_client_working_folders + .clone() + .map(Some) + .or(existing.download_client_working_folders); + + resource.enable_completed_download_handling = config + .spec + .enable_completed_download_handling + .or(existing.enable_completed_download_handling); + + resource.auto_redownload_failed = config + .spec + .auto_redownload_failed + .or(existing.auto_redownload_failed); + + resource.auto_redownload_failed_from_interactive_search = config + .spec + .auto_redownload_failed_from_interactive_search + .or(existing.auto_redownload_failed_from_interactive_search); + + // Update config + let id = existing + .id + .ok_or(Error::MissingObjectKey("download_client_config.id"))?; + download_client_config_api::update_download_client_config( + &sonarr_config, + &id.to_string(), + Some(resource), + ) + .await?; + + // Update status + let api: Api = Api::namespaced(client.clone(), &namespace); + let mut conditions = config + .status + .as_ref() + .map(|s| s.conditions.clone()) + .unwrap_or_default(); + update_conditions( + &mut conditions, + ready_condition( + true, + "Synced", + "Download client config synchronized with Sonarr", + ), + ); + + let status = SonarrDownloadClientConfigStatus { + conditions, + observed_generation: config.metadata.generation.unwrap_or(0), + }; + + api.patch_status( + &name, + &PatchParams::apply("sonarr-operator"), + &Patch::Merge(serde_json::json!({ "status": status })), + ) + .await?; + + Ok(Action::requeue(REQUEUE_DURATION)) +} + +async fn reconcile_cleanup( + _config: Arc, + _ctx: Arc, +) -> Result { + // Config settings persist in Sonarr, nothing to clean up + Ok(Action::await_change()) +} diff --git a/src/controllers/import_list.rs b/src/controllers/import_list.rs new file mode 100644 index 0000000..e86f4da --- /dev/null +++ b/src/controllers/import_list.rs @@ -0,0 +1,186 @@ +use std::sync::Arc; + +use kube::api::{Api, Patch, PatchParams}; +use kube::runtime::controller::Action; +use kube::{Client, ResourceExt}; +use tracing::info; + +use sonarr::apis::import_list_api; +use sonarr::models::ImportListResource; + +use crate::Context; +use crate::crds::import_list::{ImportListType, MonitorTypes, NewItemMonitorTypes, SeriesTypes}; +use crate::crds::{SonarrImportList, SonarrImportListStatus}; +use crate::error::{Error, Result}; + +use super::tag::get_sonarr_config; +use super::traits::{REQUEUE_DURATION, reconcile_with_finalizer, run_controller}; +use super::{ready_condition, update_conditions}; + +/// Start the SonarrImportList controller +pub async fn run(client: Client, context: Arc) { + run_controller::(client, context, "SonarrImportList", reconcile).await; +} + +async fn reconcile(obj: Arc, ctx: Arc) -> Result { + reconcile_with_finalizer(obj, ctx, reconcile_apply, reconcile_cleanup).await +} + +async fn reconcile_apply(import_list: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = import_list + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + let name = import_list.name_any(); + + info!("Reconciling SonarrImportList: {}/{}", namespace, name); + + let config = get_sonarr_config( + &ctx, + client, + &namespace, + &import_list.spec.sonarr_instance_ref, + ) + .await?; + + // Build import list resource + let mut resource = ImportListResource::new(); + resource.name = Some(Some(import_list.spec.name.clone())); + resource.implementation = Some(Some( + get_implementation_name(&import_list.spec.list_type).to_string(), + )); + resource.enable_automatic_add = Some(import_list.spec.enable_automatic_add); + resource.search_for_missing_episodes = Some(import_list.spec.search_for_missing_episodes); + resource.should_monitor = Some(convert_monitor_type(&import_list.spec.should_monitor)); + resource.monitor_new_items = Some(convert_new_item_monitor_type( + &import_list.spec.monitor_new_items, + )); + resource.root_folder_path = Some(Some(import_list.spec.root_folder_path.clone())); + resource.quality_profile_id = Some(import_list.spec.quality_profile_id); + resource.series_type = Some(convert_series_type(&import_list.spec.series_type)); + resource.season_folder = Some(import_list.spec.season_folder); + resource.list_order = Some(import_list.spec.list_order); + resource.tags = Some(Some(import_list.spec.tags.clone())); + + let sonarr_import_list = if let Some(id) = import_list.status.as_ref().and_then(|s| s.id) { + resource.id = Some(id); + match import_list_api::update_import_list(&config, id, Some(false), Some(resource.clone())) + .await + { + Ok(i) => i, + Err(_) => { + resource.id = None; + import_list_api::create_import_list(&config, Some(false), Some(resource)).await? + } + } + } else { + let existing = import_list_api::list_import_list(&config).await?; + if let Some(existing_item) = existing + .iter() + .find(|i| i.name.as_ref().and_then(|n| n.as_ref()) == Some(&import_list.spec.name)) + { + existing_item.clone() + } else { + import_list_api::create_import_list(&config, Some(false), Some(resource)).await? + } + }; + + // Update status + let api: Api = Api::namespaced(client.clone(), &namespace); + let mut conditions = import_list + .status + .as_ref() + .map(|s| s.conditions.clone()) + .unwrap_or_default(); + update_conditions( + &mut conditions, + ready_condition(true, "Synced", "Import list synchronized with Sonarr"), + ); + + let status = SonarrImportListStatus { + conditions, + id: sonarr_import_list.id, + observed_generation: import_list.metadata.generation.unwrap_or(0), + }; + + let status_patch = serde_json::json!({ "status": status }); + api.patch_status(&name, &PatchParams::default(), &Patch::Merge(&status_patch)) + .await?; + + Ok(Action::requeue(REQUEUE_DURATION)) +} + +fn get_implementation_name(list_type: &ImportListType) -> &'static str { + match list_type { + ImportListType::SonarrImport => "SonarrImport", + ImportListType::TraktListImport => "TraktListImport", + ImportListType::TraktUserImport => "TraktUserImport", + ImportListType::TraktPopularImport => "TraktPopularImport", + ImportListType::PlexImport => "PlexImport", + ImportListType::ImdbListImport => "ImdbListImport", + ImportListType::CustomImport => "CustomImport", + ImportListType::SimklImport => "SimklImport", + ImportListType::AniListImport => "AniListImport", + ImportListType::MyAnimeListImport => "MyAnimeListImport", + } +} + +fn convert_monitor_type(mt: &MonitorTypes) -> sonarr::models::MonitorTypes { + match mt { + MonitorTypes::All => sonarr::models::MonitorTypes::All, + MonitorTypes::Future => sonarr::models::MonitorTypes::Future, + MonitorTypes::Missing => sonarr::models::MonitorTypes::Missing, + MonitorTypes::Existing => sonarr::models::MonitorTypes::Existing, + MonitorTypes::FirstSeason => sonarr::models::MonitorTypes::FirstSeason, + MonitorTypes::LatestSeason => sonarr::models::MonitorTypes::LatestSeason, + MonitorTypes::Pilot => sonarr::models::MonitorTypes::Pilot, + MonitorTypes::MonitorSpecials => sonarr::models::MonitorTypes::MonitorSpecials, + MonitorTypes::UnmonitorSpecials => sonarr::models::MonitorTypes::UnmonitorSpecials, + MonitorTypes::None => sonarr::models::MonitorTypes::None, + } +} + +fn convert_new_item_monitor_type(mt: &NewItemMonitorTypes) -> sonarr::models::NewItemMonitorTypes { + match mt { + NewItemMonitorTypes::All => sonarr::models::NewItemMonitorTypes::All, + NewItemMonitorTypes::None => sonarr::models::NewItemMonitorTypes::None, + } +} + +fn convert_series_type(st: &SeriesTypes) -> sonarr::models::SeriesTypes { + match st { + SeriesTypes::Standard => sonarr::models::SeriesTypes::Standard, + SeriesTypes::Daily => sonarr::models::SeriesTypes::Daily, + SeriesTypes::Anime => sonarr::models::SeriesTypes::Anime, + } +} + +async fn reconcile_cleanup( + import_list: Arc, + ctx: Arc, +) -> Result { + let client = &ctx.client; + let namespace = import_list + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + + info!( + "Cleaning up SonarrImportList: {}/{}", + namespace, + import_list.name_any() + ); + + if let Some(id) = import_list.status.as_ref().and_then(|s| s.id) + && let Ok(config) = get_sonarr_config( + &ctx, + client, + &namespace, + &import_list.spec.sonarr_instance_ref, + ) + .await + { + let _ = import_list_api::delete_import_list(&config, id).await; + } + + Ok(Action::await_change()) +} diff --git a/src/controllers/indexer.rs b/src/controllers/indexer.rs new file mode 100644 index 0000000..d32a9a5 --- /dev/null +++ b/src/controllers/indexer.rs @@ -0,0 +1,181 @@ +use std::sync::Arc; + +use k8s_openapi::api::core::v1::Secret; +use kube::api::{Api, Patch, PatchParams}; +use kube::runtime::controller::Action; +use kube::{Client, ResourceExt}; +use tracing::info; + +use sonarr::apis::indexer_api; +use sonarr::models::IndexerResource; + +use crate::Context; +use crate::crds::indexer::IndexerType; +use crate::crds::{SonarrIndexer, SonarrIndexerStatus}; +use crate::error::{Error, Result}; + +use super::tag::get_sonarr_config; +use super::traits::{REQUEUE_DURATION, reconcile_with_finalizer, run_controller}; +use super::{ready_condition, update_conditions}; + +/// Start the SonarrIndexer controller +pub async fn run(client: Client, context: Arc) { + run_controller::(client, context, "SonarrIndexer", reconcile).await; +} + +async fn reconcile(obj: Arc, ctx: Arc) -> Result { + reconcile_with_finalizer(obj, ctx, reconcile_apply, reconcile_cleanup).await +} + +async fn reconcile_apply(indexer: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = indexer + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + let name = indexer.name_any(); + + info!("Reconciling SonarrIndexer: {}/{}", namespace, name); + + let config = + get_sonarr_config(&ctx, client, &namespace, &indexer.spec.sonarr_instance_ref).await?; + + // Resolve API key from secret if needed + // TODO: Use the api_key in indexer fields/config when setting up the indexer resource + let _api_key = resolve_secret_value( + client, + &namespace, + indexer.spec.config.api_key.clone(), + indexer.spec.config.api_key_secret_ref.as_ref(), + ) + .await?; + + // Build indexer resource + let mut idx_resource = IndexerResource::new(); + idx_resource.name = Some(Some(indexer.spec.name.clone())); + idx_resource.implementation = Some(Some( + get_implementation_name(&indexer.spec.indexer_type).to_string(), + )); + idx_resource.enable_rss = Some(indexer.spec.enable_rss); + idx_resource.enable_automatic_search = Some(indexer.spec.enable_automatic_search); + idx_resource.enable_interactive_search = Some(indexer.spec.enable_interactive_search); + idx_resource.priority = Some(indexer.spec.priority); + idx_resource.tags = Some(Some(indexer.spec.tags.clone())); + + // Fields would need more complex mapping for the sonarr crate + + let sonarr_indexer = if let Some(id) = indexer.status.as_ref().and_then(|s| s.id) { + idx_resource.id = Some(id); + match indexer_api::update_indexer(&config, id, Some(false), Some(idx_resource.clone())) + .await + { + Ok(i) => i, + Err(_) => { + idx_resource.id = None; + indexer_api::create_indexer(&config, Some(false), Some(idx_resource)).await? + } + } + } else { + let existing = indexer_api::list_indexer(&config).await?; + if let Some(existing_idx) = existing + .iter() + .find(|i| i.name.as_ref().and_then(|n| n.as_ref()) == Some(&indexer.spec.name)) + { + existing_idx.clone() + } else { + indexer_api::create_indexer(&config, Some(false), Some(idx_resource)).await? + } + }; + + // Update status + let indexers_api: Api = Api::namespaced(client.clone(), &namespace); + let mut conditions = indexer + .status + .as_ref() + .map(|s| s.conditions.clone()) + .unwrap_or_default(); + update_conditions( + &mut conditions, + ready_condition(true, "Synced", "Indexer synchronized with Sonarr"), + ); + + let status = SonarrIndexerStatus { + conditions, + id: sonarr_indexer.id, + observed_generation: indexer.metadata.generation.unwrap_or(0), + }; + + let status_patch = serde_json::json!({ "status": status }); + indexers_api + .patch_status(&name, &PatchParams::default(), &Patch::Merge(&status_patch)) + .await?; + + Ok(Action::requeue(REQUEUE_DURATION)) +} + +fn get_implementation_name(indexer_type: &IndexerType) -> &'static str { + match indexer_type { + IndexerType::Newznab => "Newznab", + IndexerType::Torznab => "Torznab", + IndexerType::Fanzub => "Fanzub", + IndexerType::BroadcastheNet => "BroadcastheNet", + IndexerType::FileList => "FileList", + IndexerType::HDBits => "HDBits", + IndexerType::IPTorrents => "IPTorrents", + IndexerType::Nyaa => "Nyaa", + IndexerType::TorrentRss => "TorrentRssIndexer", + IndexerType::TorrentLeech => "TorrentLeech", + } +} + +pub async fn resolve_secret_value( + client: &Client, + namespace: &str, + direct_value: Option, + secret_ref: Option<&crate::crds::SecretKeySelector>, +) -> Result> { + if let Some(value) = direct_value { + return Ok(Some(value)); + } + + if let Some(secret_ref) = secret_ref { + let secrets: Api = Api::namespaced(client.clone(), namespace); + let secret = secrets + .get(&secret_ref.name) + .await + .map_err(|_| Error::MissingApiCredentials)?; + + let data = secret.data.ok_or(Error::MissingApiCredentials)?; + let value_bytes = data + .get(&secret_ref.key) + .ok_or(Error::MissingApiCredentials)?; + + let value = + String::from_utf8(value_bytes.0.clone()).map_err(|_| Error::MissingApiCredentials)?; + + return Ok(Some(value)); + } + + Ok(None) +} + +async fn reconcile_cleanup(indexer: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = indexer + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + + info!( + "Cleaning up SonarrIndexer: {}/{}", + namespace, + indexer.name_any() + ); + + if let Some(id) = indexer.status.as_ref().and_then(|s| s.id) + && let Ok(config) = + get_sonarr_config(&ctx, client, &namespace, &indexer.spec.sonarr_instance_ref).await + { + let _ = indexer_api::delete_indexer(&config, id).await; + } + + Ok(Action::await_change()) +} diff --git a/src/controllers/indexer_config.rs b/src/controllers/indexer_config.rs new file mode 100644 index 0000000..0b7feb2 --- /dev/null +++ b/src/controllers/indexer_config.rs @@ -0,0 +1,174 @@ +//! Controller for SonarrIndexerConfig +//! +//! This controller manages global indexer configuration for Sonarr instances. +//! Only one SonarrIndexerConfig per Sonarr instance is allowed. + +use std::sync::Arc; + +use kube::api::{Api, ListParams, Patch, PatchParams}; +use kube::runtime::controller::Action; +use kube::{Client, ResourceExt}; +use tracing::{info, warn}; + +use sonarr::apis::indexer_config_api; +use sonarr::models::IndexerConfigResource; + +use crate::Context; +use crate::crds::indexer_config::{SonarrIndexerConfig, SonarrIndexerConfigStatus}; +use crate::error::{Error, Result}; + +use super::tag::get_sonarr_config; +use super::traits::{REQUEUE_DURATION, reconcile_with_finalizer, run_controller}; +use super::{ready_condition, update_conditions}; + +/// Start the SonarrIndexerConfig controller +pub async fn run(client: Client, context: Arc) { + run_controller::(client, context, "SonarrIndexerConfig", reconcile) + .await; +} + +async fn reconcile(obj: Arc, ctx: Arc) -> Result { + reconcile_with_finalizer(obj, ctx, reconcile_apply, reconcile_cleanup).await +} + +/// Check if another SonarrIndexerConfig exists for the same Sonarr instance +async fn check_singleton( + client: &Client, + namespace: &str, + current_name: &str, + instance_ref_name: &str, + instance_ref_namespace: Option<&str>, +) -> Result> { + let api: Api = Api::namespaced(client.clone(), namespace); + let configs = api.list(&ListParams::default()).await?; + + for config in configs.items { + let config_name = config.name_any(); + if config_name == current_name { + continue; + } + + let ref_name = &config.spec.sonarr_instance_ref.name; + let ref_ns = config.spec.sonarr_instance_ref.namespace.as_deref(); + + if ref_name == instance_ref_name && ref_ns == instance_ref_namespace { + return Ok(Some(config_name)); + } + } + + Ok(None) +} + +async fn reconcile_apply(config: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = config + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + let name = config.name_any(); + + info!("Reconciling SonarrIndexerConfig: {}/{}", namespace, name); + + // Check singleton constraint + if let Some(existing_name) = check_singleton( + client, + &namespace, + &name, + &config.spec.sonarr_instance_ref.name, + config.spec.sonarr_instance_ref.namespace.as_deref(), + ) + .await? + { + warn!( + "Another SonarrIndexerConfig '{}' already exists for Sonarr instance '{}'. Only one config per instance is allowed.", + existing_name, config.spec.sonarr_instance_ref.name + ); + + let api: Api = Api::namespaced(client.clone(), &namespace); + let mut conditions = config + .status + .as_ref() + .map(|s| s.conditions.clone()) + .unwrap_or_default(); + update_conditions( + &mut conditions, + ready_condition( + false, + "Conflict", + &format!( + "Another config '{}' already exists for this Sonarr instance", + existing_name + ), + ), + ); + + let status = SonarrIndexerConfigStatus { + conditions, + observed_generation: config.metadata.generation.unwrap_or(0), + }; + + api.patch_status( + &name, + &PatchParams::apply("sonarr-operator"), + &Patch::Merge(serde_json::json!({ "status": status })), + ) + .await?; + + return Ok(Action::requeue(REQUEUE_DURATION)); + } + + let sonarr_config = + get_sonarr_config(&ctx, client, &namespace, &config.spec.sonarr_instance_ref).await?; + + // Get existing config (there's only one, id=1) + let existing = indexer_config_api::get_indexer_config(&sonarr_config).await?; + + // Build update resource + let mut resource = IndexerConfigResource::new(); + resource.id = existing.id; + + resource.minimum_age = config.spec.minimum_age.or(existing.minimum_age); + resource.retention = config.spec.retention.or(existing.retention); + resource.maximum_size = config.spec.maximum_size.or(existing.maximum_size); + resource.rss_sync_interval = config.spec.rss_sync_interval.or(existing.rss_sync_interval); + + // Update config + let id = existing + .id + .ok_or(Error::MissingObjectKey("indexer_config.id"))?; + indexer_config_api::update_indexer_config(&sonarr_config, &id.to_string(), Some(resource)) + .await?; + + // Update status + let api: Api = Api::namespaced(client.clone(), &namespace); + let mut conditions = config + .status + .as_ref() + .map(|s| s.conditions.clone()) + .unwrap_or_default(); + update_conditions( + &mut conditions, + ready_condition(true, "Synced", "Indexer config synchronized with Sonarr"), + ); + + let status = SonarrIndexerConfigStatus { + conditions, + observed_generation: config.metadata.generation.unwrap_or(0), + }; + + api.patch_status( + &name, + &PatchParams::apply("sonarr-operator"), + &Patch::Merge(serde_json::json!({ "status": status })), + ) + .await?; + + Ok(Action::requeue(REQUEUE_DURATION)) +} + +async fn reconcile_cleanup( + _config: Arc, + _ctx: Arc, +) -> Result { + // Config settings persist in Sonarr, nothing to clean up + Ok(Action::await_change()) +} diff --git a/src/controllers/language_profile.rs b/src/controllers/language_profile.rs new file mode 100644 index 0000000..be247f8 --- /dev/null +++ b/src/controllers/language_profile.rs @@ -0,0 +1,205 @@ +use std::sync::Arc; + +use kube::api::{Api, Patch, PatchParams}; +use kube::runtime::controller::Action; +use kube::{Client, ResourceExt}; +use tracing::info; + +use sonarr::apis::language_profile_api; +use sonarr::models::{Language, LanguageProfileItemResource, LanguageProfileResource}; + +use crate::Context; +use crate::crds::language_profile::LanguageType; +use crate::crds::{SonarrLanguageProfile, SonarrLanguageProfileStatus}; +use crate::error::{Error, Result}; + +use super::tag::get_sonarr_config; +use super::traits::{REQUEUE_DURATION, reconcile_with_finalizer, run_controller}; +use super::{ready_condition, update_conditions}; + +/// Start the SonarrLanguageProfile controller +pub async fn run(client: Client, context: Arc) { + run_controller::( + client, + context, + "SonarrLanguageProfile", + reconcile, + ) + .await; +} + +async fn reconcile(obj: Arc, ctx: Arc) -> Result { + reconcile_with_finalizer(obj, ctx, reconcile_apply, reconcile_cleanup).await +} + +async fn reconcile_apply(profile: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = profile + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + let name = profile.name_any(); + + info!("Reconciling SonarrLanguageProfile: {}/{}", namespace, name); + + let config = + get_sonarr_config(&ctx, client, &namespace, &profile.spec.sonarr_instance_ref).await?; + + // Build language profile resource + let mut resource = LanguageProfileResource::new(); + resource.name = Some(Some(profile.spec.name.clone())); + resource.upgrade_allowed = Some(profile.spec.upgrade_allowed); + resource.cutoff = Some(Box::new(convert_language(&profile.spec.cutoff_language))); + + // Convert languages + let languages: Vec = profile + .spec + .languages + .iter() + .map(|item| { + let mut lang_item = LanguageProfileItemResource::new(); + lang_item.language = Some(Box::new(convert_language(&item.language))); + lang_item.allowed = Some(item.allowed); + lang_item + }) + .collect(); + resource.languages = Some(Some(languages)); + + let sonarr_profile = if let Some(id) = profile.status.as_ref().and_then(|s| s.id) { + resource.id = Some(id); + match language_profile_api::update_language_profile( + &config, + &id.to_string(), + Some(resource.clone()), + ) + .await + { + Ok(p) => p, + Err(_) => { + resource.id = None; + language_profile_api::create_language_profile(&config, Some(resource)).await? + } + } + } else { + let existing = language_profile_api::list_language_profile(&config).await?; + if let Some(existing_item) = existing + .iter() + .find(|p| p.name.as_ref().and_then(|n| n.as_ref()) == Some(&profile.spec.name)) + { + existing_item.clone() + } else { + language_profile_api::create_language_profile(&config, Some(resource)).await? + } + }; + + // Update status + let api: Api = Api::namespaced(client.clone(), &namespace); + let mut conditions = profile + .status + .as_ref() + .map(|s| s.conditions.clone()) + .unwrap_or_default(); + update_conditions( + &mut conditions, + ready_condition(true, "Synced", "Language profile synchronized with Sonarr"), + ); + + let status = SonarrLanguageProfileStatus { + conditions, + id: sonarr_profile.id, + observed_generation: profile.metadata.generation.unwrap_or(0), + }; + + let status_patch = serde_json::json!({ "status": status }); + api.patch_status(&name, &PatchParams::default(), &Patch::Merge(&status_patch)) + .await?; + + Ok(Action::requeue(REQUEUE_DURATION)) +} + +fn convert_language(lang: &LanguageType) -> Language { + let mut language = Language::new(); + language.id = Some(get_language_id(lang)); + language.name = Some(Some(format!("{:?}", lang))); + language +} + +fn get_language_id(lang: &LanguageType) -> i32 { + match lang { + LanguageType::Unknown => -1, + LanguageType::English => 1, + LanguageType::French => 2, + LanguageType::Spanish => 3, + LanguageType::German => 4, + LanguageType::Italian => 5, + LanguageType::Danish => 6, + LanguageType::Dutch => 7, + LanguageType::Japanese => 8, + LanguageType::Icelandic => 9, + LanguageType::Chinese => 10, + LanguageType::Russian => 11, + LanguageType::Polish => 12, + LanguageType::Vietnamese => 13, + LanguageType::Swedish => 14, + LanguageType::Norwegian => 15, + LanguageType::Finnish => 16, + LanguageType::Turkish => 17, + LanguageType::Portuguese => 18, + LanguageType::Flemish => 19, + LanguageType::Greek => 20, + LanguageType::Korean => 21, + LanguageType::Hungarian => 22, + LanguageType::Hebrew => 23, + LanguageType::Lithuanian => 24, + LanguageType::Czech => 25, + LanguageType::Hindi => 26, + LanguageType::Romanian => 27, + LanguageType::Thai => 28, + LanguageType::Bulgarian => 29, + LanguageType::PortugueseBrazil => 30, + LanguageType::Arabic => 31, + LanguageType::Ukrainian => 32, + LanguageType::Persian => 33, + LanguageType::Bengali => 34, + LanguageType::Slovak => 35, + LanguageType::Latvian => 36, + LanguageType::SpanishLatino => 37, + LanguageType::Catalan => 38, + LanguageType::Croatian => 39, + LanguageType::Serbian => 40, + LanguageType::Bosnian => 41, + LanguageType::Estonian => 42, + LanguageType::Tamil => 43, + LanguageType::Indonesian => 44, + LanguageType::Telugu => 45, + LanguageType::Macedonian => 46, + LanguageType::Slovenian => 47, + LanguageType::Malay => 48, + LanguageType::Original => -2, + LanguageType::Any => 0, + } +} + +async fn reconcile_cleanup( + profile: Arc, + ctx: Arc, +) -> Result { + let client = &ctx.client; + let namespace = profile + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + + info!( + "Cleaning up SonarrLanguageProfile: {}/{}", + namespace, + profile.name_any() + ); + + if let Some(id) = profile.status.as_ref().and_then(|s| s.id) + && let Ok(config) = + get_sonarr_config(&ctx, client, &namespace, &profile.spec.sonarr_instance_ref).await + { + let _ = language_profile_api::delete_language_profile(&config, id).await; + } + + Ok(Action::await_change()) +} diff --git a/src/controllers/media_management_config.rs b/src/controllers/media_management_config.rs new file mode 100644 index 0000000..d3bf558 --- /dev/null +++ b/src/controllers/media_management_config.rs @@ -0,0 +1,332 @@ +//! Controller for SonarrMediaManagementConfig +//! +//! This controller manages media management configuration for Sonarr instances. +//! Only one SonarrMediaManagementConfig per Sonarr instance is allowed. + +use std::sync::Arc; + +use kube::api::{Api, ListParams, Patch, PatchParams}; +use kube::runtime::controller::Action; +use kube::{Client, ResourceExt}; +use tracing::{info, warn}; + +use sonarr::apis::media_management_config_api; +use sonarr::models::{ + EpisodeTitleRequiredType, FileDateType, MediaManagementConfigResource, ProperDownloadTypes, + RescanAfterRefreshType, +}; + +use crate::Context; +use crate::crds::media_management_config::{ + EpisodeTitleRequiredType as CrdEpisodeTitleRequiredType, FileDateType as CrdFileDateType, + ProperDownloadType, RescanAfterRefreshType as CrdRescanType, SonarrMediaManagementConfig, + SonarrMediaManagementConfigStatus, +}; +use crate::error::{Error, Result}; + +use super::tag::get_sonarr_config; +use super::traits::{REQUEUE_DURATION, reconcile_with_finalizer, run_controller}; +use super::{ready_condition, update_conditions}; + +/// Start the SonarrMediaManagementConfig controller +pub async fn run(client: Client, context: Arc) { + run_controller::( + client, + context, + "SonarrMediaManagementConfig", + reconcile, + ) + .await; +} + +async fn reconcile(obj: Arc, ctx: Arc) -> Result { + reconcile_with_finalizer(obj, ctx, reconcile_apply, reconcile_cleanup).await +} + +/// Check if another SonarrMediaManagementConfig exists for the same Sonarr instance +async fn check_singleton( + client: &Client, + namespace: &str, + current_name: &str, + instance_ref_name: &str, + instance_ref_namespace: Option<&str>, +) -> Result> { + let api: Api = Api::namespaced(client.clone(), namespace); + let configs = api.list(&ListParams::default()).await?; + + for config in configs.items { + let config_name = config.name_any(); + if config_name == current_name { + continue; + } + + let ref_name = &config.spec.sonarr_instance_ref.name; + let ref_ns = config.spec.sonarr_instance_ref.namespace.as_deref(); + + if ref_name == instance_ref_name && ref_ns == instance_ref_namespace { + // Found another config for the same instance + // Check which one is older + let current_created = config.metadata.creation_timestamp.as_ref(); + if current_created.is_some() { + return Ok(Some(config_name)); + } + } + } + + Ok(None) +} + +async fn reconcile_apply( + config: Arc, + ctx: Arc, +) -> Result { + let client = &ctx.client; + let namespace = config + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + let name = config.name_any(); + + info!( + "Reconciling SonarrMediaManagementConfig: {}/{}", + namespace, name + ); + + // Check singleton constraint + if let Some(existing_name) = check_singleton( + client, + &namespace, + &name, + &config.spec.sonarr_instance_ref.name, + config.spec.sonarr_instance_ref.namespace.as_deref(), + ) + .await? + { + warn!( + "Another SonarrMediaManagementConfig '{}' already exists for Sonarr instance '{}'. Only one config per instance is allowed.", + existing_name, config.spec.sonarr_instance_ref.name + ); + + // Update status to show conflict + let api: Api = Api::namespaced(client.clone(), &namespace); + let mut conditions = config + .status + .as_ref() + .map(|s| s.conditions.clone()) + .unwrap_or_default(); + update_conditions( + &mut conditions, + ready_condition( + false, + "Conflict", + &format!( + "Another config '{}' already exists for this Sonarr instance", + existing_name + ), + ), + ); + + let status = SonarrMediaManagementConfigStatus { + conditions, + observed_generation: config.metadata.generation.unwrap_or(0), + }; + + api.patch_status( + &name, + &PatchParams::apply("sonarr-operator"), + &Patch::Merge(serde_json::json!({ "status": status })), + ) + .await?; + + return Ok(Action::requeue(REQUEUE_DURATION)); + } + + let sonarr_config = + get_sonarr_config(&ctx, client, &namespace, &config.spec.sonarr_instance_ref).await?; + + // Get existing config (there's only one, id=1) + let existing = media_management_config_api::get_media_management_config(&sonarr_config).await?; + + // Build update resource + let mut resource = MediaManagementConfigResource::new(); + resource.id = existing.id; + + // Apply settings from CRD, falling back to existing values + resource.auto_unmonitor_previously_downloaded_episodes = config + .spec + .auto_unmonitor_previously_downloaded_episodes + .or(existing.auto_unmonitor_previously_downloaded_episodes); + + resource.recycle_bin = config + .spec + .recycle_bin + .clone() + .map(Some) + .or(existing.recycle_bin); + + resource.recycle_bin_cleanup_days = config + .spec + .recycle_bin_cleanup_days + .or(existing.recycle_bin_cleanup_days); + + resource.download_propers_and_repacks = config + .spec + .download_propers_and_repacks + .as_ref() + .map(|v| match v { + ProperDownloadType::DoNotPrefer => ProperDownloadTypes::DoNotPrefer, + ProperDownloadType::PreferAndUpgrade => ProperDownloadTypes::PreferAndUpgrade, + ProperDownloadType::DoNotUpgrade => ProperDownloadTypes::DoNotUpgrade, + }) + .or(existing.download_propers_and_repacks); + + resource.create_empty_series_folders = config + .spec + .create_empty_series_folders + .or(existing.create_empty_series_folders); + + resource.delete_empty_folders = config + .spec + .delete_empty_folders + .or(existing.delete_empty_folders); + + resource.file_date = config + .spec + .file_date + .as_ref() + .map(|v| match v { + CrdFileDateType::None => FileDateType::None, + CrdFileDateType::LocalAirDate => FileDateType::LocalAirDate, + CrdFileDateType::UtcAirDate => FileDateType::UtcAirDate, + }) + .or(existing.file_date); + + resource.rescan_after_refresh = config + .spec + .rescan_after_refresh + .as_ref() + .map(|v| match v { + CrdRescanType::Always => RescanAfterRefreshType::Always, + CrdRescanType::AfterManual => RescanAfterRefreshType::AfterManual, + CrdRescanType::Never => RescanAfterRefreshType::Never, + }) + .or(existing.rescan_after_refresh); + + resource.set_permissions_linux = config + .spec + .set_permissions_linux + .or(existing.set_permissions_linux); + + resource.chmod_folder = config + .spec + .chmod_folder + .clone() + .map(Some) + .or(existing.chmod_folder); + + resource.chown_group = config + .spec + .chown_group + .clone() + .map(Some) + .or(existing.chown_group); + + resource.episode_title_required = config + .spec + .episode_title_required + .as_ref() + .map(|v| match v { + CrdEpisodeTitleRequiredType::Always => EpisodeTitleRequiredType::Always, + CrdEpisodeTitleRequiredType::BulkSeasonReleases => { + EpisodeTitleRequiredType::BulkSeasonReleases + } + CrdEpisodeTitleRequiredType::Never => EpisodeTitleRequiredType::Never, + }) + .or(existing.episode_title_required); + + resource.skip_free_space_check_when_importing = config + .spec + .skip_free_space_check_when_importing + .or(existing.skip_free_space_check_when_importing); + + resource.minimum_free_space_when_importing = config + .spec + .minimum_free_space_when_importing + .or(existing.minimum_free_space_when_importing); + + resource.copy_using_hardlinks = config + .spec + .copy_using_hardlinks + .or(existing.copy_using_hardlinks); + + resource.use_script_import = config.spec.use_script_import.or(existing.use_script_import); + + resource.script_import_path = config + .spec + .script_import_path + .clone() + .map(Some) + .or(existing.script_import_path); + + resource.import_extra_files = config + .spec + .import_extra_files + .or(existing.import_extra_files); + + resource.extra_file_extensions = config + .spec + .extra_file_extensions + .clone() + .map(Some) + .or(existing.extra_file_extensions); + + resource.enable_media_info = config.spec.enable_media_info.or(existing.enable_media_info); + + // Update config + let id = existing + .id + .ok_or(Error::MissingObjectKey("media_management_config.id"))?; + media_management_config_api::update_media_management_config( + &sonarr_config, + &id.to_string(), + Some(resource), + ) + .await?; + + // Update status + let api: Api = Api::namespaced(client.clone(), &namespace); + let mut conditions = config + .status + .as_ref() + .map(|s| s.conditions.clone()) + .unwrap_or_default(); + update_conditions( + &mut conditions, + ready_condition( + true, + "Synced", + "Media management config synchronized with Sonarr", + ), + ); + + let status = SonarrMediaManagementConfigStatus { + conditions, + observed_generation: config.metadata.generation.unwrap_or(0), + }; + + api.patch_status( + &name, + &PatchParams::apply("sonarr-operator"), + &Patch::Merge(serde_json::json!({ "status": status })), + ) + .await?; + + Ok(Action::requeue(REQUEUE_DURATION)) +} + +async fn reconcile_cleanup( + _config: Arc, + _ctx: Arc, +) -> Result { + // Config settings persist in Sonarr, nothing to clean up + Ok(Action::await_change()) +} diff --git a/src/controllers/metadata.rs b/src/controllers/metadata.rs new file mode 100644 index 0000000..98057aa --- /dev/null +++ b/src/controllers/metadata.rs @@ -0,0 +1,138 @@ +use std::sync::Arc; + +use kube::api::{Api, Patch, PatchParams}; +use kube::runtime::controller::Action; +use kube::{Client, ResourceExt}; +use tracing::info; + +use sonarr::apis::metadata_api; +use sonarr::models::MetadataResource; + +use crate::Context; +use crate::crds::metadata::MetadataType; +use crate::crds::{SonarrMetadata, SonarrMetadataStatus}; +use crate::error::{Error, Result}; + +use super::tag::get_sonarr_config; +use super::traits::{REQUEUE_DURATION, reconcile_with_finalizer, run_controller}; +use super::{ready_condition, update_conditions}; + +/// Start the SonarrMetadata controller +pub async fn run(client: Client, context: Arc) { + run_controller::(client, context, "SonarrMetadata", reconcile).await; +} + +async fn reconcile(obj: Arc, ctx: Arc) -> Result { + reconcile_with_finalizer(obj, ctx, reconcile_apply, reconcile_cleanup).await +} + +async fn reconcile_apply(metadata: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = metadata + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + let name = metadata.name_any(); + + info!("Reconciling SonarrMetadata: {}/{}", namespace, name); + + let config = + get_sonarr_config(&ctx, client, &namespace, &metadata.spec.sonarr_instance_ref).await?; + + // Build metadata resource + let mut resource = MetadataResource::new(); + resource.name = Some(Some(metadata.spec.name.clone())); + resource.implementation = Some(Some( + get_implementation_name(&metadata.spec.metadata_type).to_string(), + )); + resource.config_contract = Some(Some( + get_config_contract(&metadata.spec.metadata_type).to_string(), + )); + resource.enable = Some(metadata.spec.enable); + resource.tags = Some(Some(metadata.spec.tags.clone())); + + // Fields would need more complex mapping for the sonarr crate + + let sonarr_metadata = if let Some(id) = metadata.status.as_ref().and_then(|s| s.id) { + resource.id = Some(id); + match metadata_api::update_metadata(&config, id, Some(false), Some(resource.clone())).await + { + Ok(m) => m, + Err(_) => { + resource.id = None; + metadata_api::create_metadata(&config, Some(false), Some(resource)).await? + } + } + } else { + let existing = metadata_api::list_metadata(&config).await?; + if let Some(existing_item) = existing + .iter() + .find(|m| m.name.as_ref().and_then(|n| n.as_ref()) == Some(&metadata.spec.name)) + { + existing_item.clone() + } else { + metadata_api::create_metadata(&config, Some(false), Some(resource)).await? + } + }; + + // Update status + let api: Api = Api::namespaced(client.clone(), &namespace); + let mut conditions = metadata + .status + .as_ref() + .map(|s| s.conditions.clone()) + .unwrap_or_default(); + update_conditions( + &mut conditions, + ready_condition(true, "Synced", "Metadata synchronized with Sonarr"), + ); + + let status = SonarrMetadataStatus { + conditions, + id: sonarr_metadata.id, + observed_generation: metadata.metadata.generation.unwrap_or(0), + }; + + let status_patch = serde_json::json!({ "status": status }); + api.patch_status(&name, &PatchParams::default(), &Patch::Merge(&status_patch)) + .await?; + + Ok(Action::requeue(REQUEUE_DURATION)) +} + +fn get_implementation_name(metadata_type: &MetadataType) -> &'static str { + match metadata_type { + MetadataType::XbmcMetadata => "XbmcMetadata", + MetadataType::RoksboxMetadata => "RoksboxMetadata", + MetadataType::WdtvMetadata => "WdtvMetadata", + } +} + +fn get_config_contract(metadata_type: &MetadataType) -> &'static str { + match metadata_type { + MetadataType::XbmcMetadata => "XbmcMetadataSettings", + MetadataType::RoksboxMetadata => "RoksboxMetadataSettings", + MetadataType::WdtvMetadata => "WdtvMetadataSettings", + } +} + +async fn reconcile_cleanup(metadata: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = metadata + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + + info!( + "Cleaning up SonarrMetadata: {}/{}", + namespace, + metadata.name_any() + ); + + if let Some(id) = metadata.status.as_ref().and_then(|s| s.id) + && let Ok(config) = + get_sonarr_config(&ctx, client, &namespace, &metadata.spec.sonarr_instance_ref).await + { + let _ = metadata_api::delete_metadata(&config, id).await; + } + + Ok(Action::await_change()) +} diff --git a/src/controllers/mod.rs b/src/controllers/mod.rs new file mode 100644 index 0000000..15f81ac --- /dev/null +++ b/src/controllers/mod.rs @@ -0,0 +1,27 @@ +pub mod auto_tag; +pub mod custom_format; +pub mod delay_profile; +pub mod download_client; +pub mod download_client_config; +pub mod import_list; +pub mod indexer; +pub mod indexer_config; +pub mod language_profile; +pub mod media_management_config; +pub mod metadata; +pub mod naming_config; +pub mod notification; +pub mod quality_definition; +pub mod quality_profile; +pub mod root_folder; +pub mod series; +pub mod sonarr; +pub mod tag; +pub mod traits; +mod utils; + +pub use traits::{ + HasSonarrInstanceRef, REQUEUE_DURATION, get_sonarr_config, run_controller, + update_status_failure, update_status_success, +}; +pub use utils::*; diff --git a/src/controllers/naming_config.rs b/src/controllers/naming_config.rs new file mode 100644 index 0000000..8e96a58 --- /dev/null +++ b/src/controllers/naming_config.rs @@ -0,0 +1,232 @@ +//! Controller for SonarrNamingConfig +//! +//! This controller manages episode naming configuration for Sonarr instances. +//! Only one SonarrNamingConfig per Sonarr instance is allowed. + +use std::sync::Arc; + +use kube::api::{Api, ListParams, Patch, PatchParams}; +use kube::runtime::controller::Action; +use kube::{Client, ResourceExt}; +use tracing::{info, warn}; + +use sonarr::apis::naming_config_api; +use sonarr::models::NamingConfigResource; + +use crate::Context; +use crate::crds::naming_config::{SonarrNamingConfig, SonarrNamingConfigStatus}; +use crate::error::{Error, Result}; + +use super::tag::get_sonarr_config; +use super::traits::{REQUEUE_DURATION, reconcile_with_finalizer, run_controller}; +use super::{ready_condition, update_conditions}; + +/// Start the SonarrNamingConfig controller +pub async fn run(client: Client, context: Arc) { + run_controller::(client, context, "SonarrNamingConfig", reconcile) + .await; +} + +async fn reconcile(obj: Arc, ctx: Arc) -> Result { + reconcile_with_finalizer(obj, ctx, reconcile_apply, reconcile_cleanup).await +} + +/// Check if another SonarrNamingConfig exists for the same Sonarr instance +async fn check_singleton( + client: &Client, + namespace: &str, + current_name: &str, + instance_ref_name: &str, + instance_ref_namespace: Option<&str>, +) -> Result> { + let api: Api = Api::namespaced(client.clone(), namespace); + let configs = api.list(&ListParams::default()).await?; + + for config in configs.items { + let config_name = config.name_any(); + if config_name == current_name { + continue; + } + + let ref_name = &config.spec.sonarr_instance_ref.name; + let ref_ns = config.spec.sonarr_instance_ref.namespace.as_deref(); + + if ref_name == instance_ref_name && ref_ns == instance_ref_namespace { + return Ok(Some(config_name)); + } + } + + Ok(None) +} + +async fn reconcile_apply(config: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = config + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + let name = config.name_any(); + + info!("Reconciling SonarrNamingConfig: {}/{}", namespace, name); + + // Check singleton constraint + if let Some(existing_name) = check_singleton( + client, + &namespace, + &name, + &config.spec.sonarr_instance_ref.name, + config.spec.sonarr_instance_ref.namespace.as_deref(), + ) + .await? + { + warn!( + "Another SonarrNamingConfig '{}' already exists for Sonarr instance '{}'. Only one config per instance is allowed.", + existing_name, config.spec.sonarr_instance_ref.name + ); + + let api: Api = Api::namespaced(client.clone(), &namespace); + let mut conditions = config + .status + .as_ref() + .map(|s| s.conditions.clone()) + .unwrap_or_default(); + update_conditions( + &mut conditions, + ready_condition( + false, + "Conflict", + &format!( + "Another config '{}' already exists for this Sonarr instance", + existing_name + ), + ), + ); + + let status = SonarrNamingConfigStatus { + conditions, + observed_generation: config.metadata.generation.unwrap_or(0), + }; + + api.patch_status( + &name, + &PatchParams::apply("sonarr-operator"), + &Patch::Merge(serde_json::json!({ "status": status })), + ) + .await?; + + return Ok(Action::requeue(REQUEUE_DURATION)); + } + + let sonarr_config = + get_sonarr_config(&ctx, client, &namespace, &config.spec.sonarr_instance_ref).await?; + + // Get existing config (there's only one, id=1) + let existing = naming_config_api::get_naming_config(&sonarr_config).await?; + + // Build update resource + let mut resource = NamingConfigResource::new(); + resource.id = existing.id; + + resource.rename_episodes = config.spec.rename_episodes.or(existing.rename_episodes); + + resource.replace_illegal_characters = config + .spec + .replace_illegal_characters + .or(existing.replace_illegal_characters); + + resource.colon_replacement_format = config + .spec + .colon_replacement_format + .or(existing.colon_replacement_format); + + resource.custom_colon_replacement_format = config + .spec + .custom_colon_replacement_format + .clone() + .map(Some) + .or(existing.custom_colon_replacement_format); + + resource.multi_episode_style = config + .spec + .multi_episode_style + .or(existing.multi_episode_style); + + resource.standard_episode_format = config + .spec + .standard_episode_format + .clone() + .map(Some) + .or(existing.standard_episode_format); + + resource.daily_episode_format = config + .spec + .daily_episode_format + .clone() + .map(Some) + .or(existing.daily_episode_format); + + resource.anime_episode_format = config + .spec + .anime_episode_format + .clone() + .map(Some) + .or(existing.anime_episode_format); + + resource.series_folder_format = config + .spec + .series_folder_format + .clone() + .map(Some) + .or(existing.series_folder_format); + + resource.season_folder_format = config + .spec + .season_folder_format + .clone() + .map(Some) + .or(existing.season_folder_format); + + resource.specials_folder_format = config + .spec + .specials_folder_format + .clone() + .map(Some) + .or(existing.specials_folder_format); + + // Update config + let id = existing + .id + .ok_or(Error::MissingObjectKey("naming_config.id"))?; + naming_config_api::update_naming_config(&sonarr_config, &id.to_string(), Some(resource)) + .await?; + + // Update status + let api: Api = Api::namespaced(client.clone(), &namespace); + let mut conditions = config + .status + .as_ref() + .map(|s| s.conditions.clone()) + .unwrap_or_default(); + update_conditions( + &mut conditions, + ready_condition(true, "Synced", "Naming config synchronized with Sonarr"), + ); + + let status = SonarrNamingConfigStatus { + conditions, + observed_generation: config.metadata.generation.unwrap_or(0), + }; + + api.patch_status( + &name, + &PatchParams::apply("sonarr-operator"), + &Patch::Merge(serde_json::json!({ "status": status })), + ) + .await?; + + Ok(Action::requeue(REQUEUE_DURATION)) +} + +async fn reconcile_cleanup(_config: Arc, _ctx: Arc) -> Result { + // Config settings persist in Sonarr, nothing to clean up + Ok(Action::await_change()) +} diff --git a/src/controllers/notification.rs b/src/controllers/notification.rs new file mode 100644 index 0000000..7ae0f09 --- /dev/null +++ b/src/controllers/notification.rs @@ -0,0 +1,188 @@ +use std::sync::Arc; + +use kube::api::{Api, Patch, PatchParams}; +use kube::runtime::controller::Action; +use kube::{Client, ResourceExt}; +use tracing::info; + +use sonarr::apis::notification_api; +use sonarr::models::NotificationResource; + +use crate::Context; +use crate::crds::notification::NotificationType; +use crate::crds::{SonarrNotification, SonarrNotificationStatus}; +use crate::error::{Error, Result}; + +use super::tag::get_sonarr_config; +use super::traits::{REQUEUE_DURATION, reconcile_with_finalizer, run_controller}; +use super::{ready_condition, update_conditions}; + +/// Start the SonarrNotification controller +pub async fn run(client: Client, context: Arc) { + run_controller::(client, context, "SonarrNotification", reconcile) + .await; +} + +async fn reconcile(obj: Arc, ctx: Arc) -> Result { + reconcile_with_finalizer(obj, ctx, reconcile_apply, reconcile_cleanup).await +} + +async fn reconcile_apply( + notification: Arc, + ctx: Arc, +) -> Result { + let client = &ctx.client; + let namespace = notification + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + let name = notification.name_any(); + + info!("Reconciling SonarrNotification: {}/{}", namespace, name); + + let config = get_sonarr_config( + &ctx, + client, + &namespace, + ¬ification.spec.sonarr_instance_ref, + ) + .await?; + + // Build notification resource + let mut n_resource = NotificationResource::new(); + n_resource.name = Some(Some(notification.spec.name.clone())); + n_resource.implementation = Some(Some( + get_implementation_name(¬ification.spec.notification_type).to_string(), + )); + n_resource.on_grab = Some(notification.spec.triggers.on_grab); + n_resource.on_download = Some(notification.spec.triggers.on_download); + n_resource.on_upgrade = Some(notification.spec.triggers.on_upgrade); + n_resource.on_rename = Some(notification.spec.triggers.on_rename); + n_resource.on_series_add = Some(notification.spec.triggers.on_series_add); + n_resource.on_series_delete = Some(notification.spec.triggers.on_series_delete); + n_resource.on_episode_file_delete = Some(notification.spec.triggers.on_episode_file_delete); + n_resource.on_episode_file_delete_for_upgrade = Some( + notification + .spec + .triggers + .on_episode_file_delete_for_upgrade, + ); + n_resource.on_health_issue = Some(notification.spec.triggers.on_health_issue); + n_resource.on_health_restored = Some(notification.spec.triggers.on_health_restored); + n_resource.on_application_update = Some(notification.spec.triggers.on_application_update); + n_resource.on_manual_interaction_required = + Some(notification.spec.triggers.on_manual_interaction_required); + n_resource.include_health_warnings = Some(notification.spec.triggers.include_health_warnings); + n_resource.tags = Some(Some(notification.spec.tags.clone())); + + let sonarr_notification = if let Some(id) = notification.status.as_ref().and_then(|s| s.id) { + n_resource.id = Some(id); + match notification_api::update_notification( + &config, + id, + Some(false), + Some(n_resource.clone()), + ) + .await + { + Ok(n) => n, + Err(_) => { + n_resource.id = None; + notification_api::create_notification(&config, Some(false), Some(n_resource)) + .await? + } + } + } else { + let existing = notification_api::list_notification(&config).await?; + if let Some(existing_n) = existing + .iter() + .find(|n| n.name.as_ref().and_then(|nm| nm.as_ref()) == Some(¬ification.spec.name)) + { + existing_n.clone() + } else { + notification_api::create_notification(&config, Some(false), Some(n_resource)).await? + } + }; + + // Update status + let notifications_api: Api = Api::namespaced(client.clone(), &namespace); + let mut conditions = notification + .status + .as_ref() + .map(|s| s.conditions.clone()) + .unwrap_or_default(); + update_conditions( + &mut conditions, + ready_condition(true, "Synced", "Notification synchronized with Sonarr"), + ); + + let status = SonarrNotificationStatus { + conditions, + id: sonarr_notification.id, + observed_generation: notification.metadata.generation.unwrap_or(0), + }; + + let status_patch = serde_json::json!({ "status": status }); + notifications_api + .patch_status(&name, &PatchParams::default(), &Patch::Merge(&status_patch)) + .await?; + + Ok(Action::requeue(REQUEUE_DURATION)) +} + +fn get_implementation_name(notification_type: &NotificationType) -> &'static str { + match notification_type { + NotificationType::Apprise => "Apprise", + NotificationType::CustomScript => "CustomScript", + NotificationType::Discord => "Discord", + NotificationType::Email => "Email", + NotificationType::Emby => "MediaBrowser", + NotificationType::Gotify => "Gotify", + NotificationType::Join => "Join", + NotificationType::Kodi => "Xbmc", + NotificationType::Mailgun => "Mailgun", + NotificationType::Ntfy => "Ntfy", + NotificationType::Plex => "PlexServer", + NotificationType::Prowl => "Prowl", + NotificationType::Pushbullet => "Pushbullet", + NotificationType::Pushover => "Pushover", + NotificationType::SendGrid => "SendGrid", + NotificationType::Signal => "Signal", + NotificationType::Simplepush => "Simplepush", + NotificationType::Slack => "Slack", + NotificationType::SynologyIndexer => "SynologyIndexer", + NotificationType::Telegram => "Telegram", + NotificationType::Trakt => "Trakt", + NotificationType::Twitter => "Twitter", + NotificationType::Webhook => "Webhook", + } +} + +async fn reconcile_cleanup( + notification: Arc, + ctx: Arc, +) -> Result { + let client = &ctx.client; + let namespace = notification + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + + info!( + "Cleaning up SonarrNotification: {}/{}", + namespace, + notification.name_any() + ); + + if let Some(id) = notification.status.as_ref().and_then(|s| s.id) + && let Ok(config) = get_sonarr_config( + &ctx, + client, + &namespace, + ¬ification.spec.sonarr_instance_ref, + ) + .await + { + let _ = notification_api::delete_notification(&config, id).await; + } + + Ok(Action::await_change()) +} diff --git a/src/controllers/quality_definition.rs b/src/controllers/quality_definition.rs new file mode 100644 index 0000000..20dd119 --- /dev/null +++ b/src/controllers/quality_definition.rs @@ -0,0 +1,130 @@ +use std::sync::Arc; + +use kube::api::{Api, Patch, PatchParams}; +use kube::runtime::controller::Action; +use kube::{Client, ResourceExt}; +use tracing::info; + +use sonarr::apis::quality_definition_api; +use sonarr::models::QualityDefinitionResource; + +use crate::Context; +use crate::crds::{SonarrQualityDefinition, SonarrQualityDefinitionStatus}; +use crate::error::{Error, Result}; + +use super::tag::get_sonarr_config; +use super::traits::{REQUEUE_DURATION, reconcile_with_finalizer, run_controller}; +use super::{ready_condition, update_conditions}; + +/// Start the SonarrQualityDefinition controller +pub async fn run(client: Client, context: Arc) { + run_controller::( + client, + context, + "SonarrQualityDefinition", + reconcile, + ) + .await; +} + +async fn reconcile(obj: Arc, ctx: Arc) -> Result { + reconcile_with_finalizer(obj, ctx, reconcile_apply, reconcile_cleanup).await +} + +async fn reconcile_apply(qd: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = qd + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + let name = qd.name_any(); + + info!( + "Reconciling SonarrQualityDefinition: {}/{}", + namespace, name + ); + + let config = get_sonarr_config(&ctx, client, &namespace, &qd.spec.sonarr_instance_ref).await?; + + // Quality definitions already exist in Sonarr, we just update them + let quality_id = qd.spec.quality_name.to_quality_id(); + + // Get existing quality definitions to find the one we want to update + let existing = quality_definition_api::list_quality_definition(&config).await?; + let existing_qd = existing + .iter() + .find(|q| q.quality.as_ref().and_then(|quality| quality.id) == Some(quality_id)); + + let sonarr_qd = if let Some(existing_item) = existing_qd { + let mut resource = QualityDefinitionResource::new(); + resource.id = existing_item.id; + resource.quality = existing_item.quality.clone(); + resource.weight = existing_item.weight; + + // Apply our customizations + if let Some(title) = &qd.spec.title { + resource.title = Some(Some(title.clone())); + } else { + resource.title = existing_item.title.clone(); + } + resource.min_size = Some(qd.spec.min_size); + resource.max_size = Some(qd.spec.max_size); + resource.preferred_size = Some(qd.spec.preferred_size); + + let id = existing_item + .id + .ok_or(Error::MissingObjectKey("quality_definition.id"))?; + quality_definition_api::update_quality_definition(&config, &id.to_string(), Some(resource)) + .await? + } else { + return Err(Error::SonarrApiError(format!( + "Quality definition for {:?} not found in Sonarr", + qd.spec.quality_name + ))); + }; + + // Update status + let api: Api = Api::namespaced(client.clone(), &namespace); + let mut conditions = qd + .status + .as_ref() + .map(|s| s.conditions.clone()) + .unwrap_or_default(); + update_conditions( + &mut conditions, + ready_condition( + true, + "Synced", + "Quality definition synchronized with Sonarr", + ), + ); + + let status = SonarrQualityDefinitionStatus { + conditions, + id: sonarr_qd.id, + observed_generation: qd.metadata.generation.unwrap_or(0), + }; + + let status_patch = serde_json::json!({ "status": status }); + api.patch_status(&name, &PatchParams::default(), &Patch::Merge(&status_patch)) + .await?; + + Ok(Action::requeue(REQUEUE_DURATION)) +} + +async fn reconcile_cleanup(qd: Arc, _ctx: Arc) -> Result { + let namespace = qd + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + + info!( + "Cleaning up SonarrQualityDefinition: {}/{}", + namespace, + qd.name_any() + ); + + // Quality definitions cannot be deleted in Sonarr, only modified + // So cleanup is a no-op - we just let the CRD be removed + // The quality definition in Sonarr will remain with its current settings + + Ok(Action::await_change()) +} diff --git a/src/controllers/quality_profile.rs b/src/controllers/quality_profile.rs new file mode 100644 index 0000000..c532a23 --- /dev/null +++ b/src/controllers/quality_profile.rs @@ -0,0 +1,269 @@ +use std::sync::Arc; + +use kube::api::{Api, Patch, PatchParams}; +use kube::runtime::controller::Action; +use kube::{Client, ResourceExt}; +use tracing::info; + +use sonarr::apis::{custom_format_api, quality_definition_api, quality_profile_api}; +use sonarr::models::{ + ProfileFormatItemResource, Quality as SonarrQuality, QualityProfileQualityItemResource, + QualityProfileResource, +}; + +use crate::Context; +use crate::crds::{SonarrQualityProfile, SonarrQualityProfileStatus}; +use crate::error::{Error, Result}; + +use super::tag::get_sonarr_config; +use super::traits::{REQUEUE_DURATION, reconcile_with_finalizer, run_controller}; +use super::{ready_condition, update_conditions}; + +/// Start the SonarrQualityProfile controller +pub async fn run(client: Client, context: Arc) { + run_controller::( + client, + context, + "SonarrQualityProfile", + reconcile, + ) + .await; +} + +async fn reconcile(obj: Arc, ctx: Arc) -> Result { + reconcile_with_finalizer(obj, ctx, reconcile_apply, reconcile_cleanup).await +} + +async fn reconcile_apply(profile: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = profile + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + let name = profile.name_any(); + + info!("Reconciling SonarrQualityProfile: {}/{}", namespace, name); + + let config = + get_sonarr_config(&ctx, client, &namespace, &profile.spec.sonarr_instance_ref).await?; + + // Fetch all quality definitions and custom formats from Sonarr + // (following the Terraform provider approach: include ALL qualities and formats) + let all_quality_defs = quality_definition_api::list_quality_definition(&config).await?; + let all_custom_formats = custom_format_api::list_custom_format(&config).await?; + + // Build quality profile resource + let mut qp_resource = QualityProfileResource::new(); + qp_resource.name = Some(Some(profile.spec.name.clone())); + qp_resource.upgrade_allowed = Some(profile.spec.upgrade_allowed); + qp_resource.cutoff = Some(profile.spec.cutoff); + qp_resource.cutoff_format_score = profile.spec.cutoff_format_score; + qp_resource.min_format_score = profile.spec.min_format_score; + qp_resource.min_upgrade_format_score = Some(profile.spec.min_upgrade_format_score.unwrap_or(1)); + + // Build full items list: allowed qualities from spec + not-allowed for the rest + qp_resource.items = Some(Some(build_quality_items( + &profile.spec.quality_groups, + &all_quality_defs, + ))); + + // Build full format items list: scored items from spec + score=0 for the rest + qp_resource.format_items = Some(Some(build_format_items( + &profile.spec.format_items, + &all_custom_formats, + ))); + + let sonarr_profile = if let Some(id) = profile.status.as_ref().and_then(|s| s.id) { + qp_resource.id = Some(id); + match quality_profile_api::update_quality_profile( + &config, + id.to_string().as_str(), + Some(qp_resource.clone()), + ) + .await + { + Ok(p) => p, + Err(_) => { + qp_resource.id = None; + quality_profile_api::create_quality_profile(&config, Some(qp_resource)).await? + } + } + } else { + let existing = quality_profile_api::list_quality_profile(&config).await?; + if let Some(existing_profile) = existing + .iter() + .find(|p| p.name.as_ref().and_then(|n| n.as_ref()) == Some(&profile.spec.name)) + { + existing_profile.clone() + } else { + quality_profile_api::create_quality_profile(&config, Some(qp_resource)).await? + } + }; + + // Update status + let profiles_api: Api = Api::namespaced(client.clone(), &namespace); + let mut conditions = profile + .status + .as_ref() + .map(|s| s.conditions.clone()) + .unwrap_or_default(); + update_conditions( + &mut conditions, + ready_condition(true, "Synced", "Quality profile synchronized with Sonarr"), + ); + + let status = SonarrQualityProfileStatus { + conditions, + id: sonarr_profile.id, + observed_generation: profile.metadata.generation.unwrap_or(0), + }; + + let status_patch = serde_json::json!({ "status": status }); + profiles_api + .patch_status(&name, &PatchParams::default(), &Patch::Merge(&status_patch)) + .await?; + + Ok(Action::requeue(REQUEUE_DURATION)) +} + +/// Build the full quality items list following the Terraform provider approach: +/// 1. Convert user-specified quality groups to allowed items +/// 2. Fill remaining quality definitions as not-allowed +/// 3. Reverse the list (higher quality to lower) +fn build_quality_items( + groups: &[crate::crds::quality_profile::QualityGroup], + all_quality_defs: &[sonarr::models::QualityDefinitionResource], +) -> Vec { + let mut allowed_quality_ids: Vec = Vec::new(); + let mut items: Vec = Vec::new(); + + // Convert user-specified quality groups to allowed items + for group in groups { + if group.qualities.len() == 1 { + // Single quality — set quality field directly (like Terraform provider) + let q = &group.qualities[0]; + let mut quality = SonarrQuality::new(); + quality.id = q.id; + quality.name = q.name.clone().map(Some); + + let mut item = QualityProfileQualityItemResource::new(); + item.allowed = Some(true); + item.quality = Some(Box::new(quality)); + // Single quality items don't set `items` (Terraform provider doesn't call SetItems) + + if let Some(id) = q.id { + allowed_quality_ids.push(id); + } + items.push(item); + } else { + // Quality group with multiple nested qualities + let sub_items: Vec = group + .qualities + .iter() + .map(|q| { + let mut quality = SonarrQuality::new(); + quality.id = q.id; + quality.name = q.name.clone().map(Some); + + if let Some(id) = q.id { + allowed_quality_ids.push(id); + } + + let mut sub_item = QualityProfileQualityItemResource::new(); + sub_item.allowed = Some(true); + sub_item.quality = Some(Box::new(quality)); + sub_item + }) + .collect(); + + let mut item = QualityProfileQualityItemResource::new(); + item.id = group.id; + item.name = group.name.clone().map(Some); + item.allowed = Some(true); + item.items = Some(Some(sub_items)); + items.push(item); + } + } + + // Fill remaining quality definitions as not-allowed + for qd in all_quality_defs { + let quality_id = qd.quality.as_ref().and_then(|q| q.id); + if let Some(id) = quality_id + && !allowed_quality_ids.contains(&id) + { + let mut quality = SonarrQuality::new(); + quality.id = Some(id); + + let mut item = QualityProfileQualityItemResource::new(); + item.allowed = Some(false); + item.items = Some(Some(vec![])); + item.quality = Some(Box::new(quality)); + items.push(item); + } + } + + // Reverse: higher quality to lower (matches Terraform provider behavior) + items.reverse(); + items +} + +/// Build the full format items list following the Terraform provider approach: +/// 1. Include user-specified format items with their scores +/// 2. Fill remaining custom formats with score=0 +fn build_format_items( + spec_items: &[crate::crds::quality_profile::FormatItem], + all_custom_formats: &[sonarr::models::CustomFormatResource], +) -> Vec { + let mut used_format_ids: Vec = Vec::new(); + let mut items: Vec = Vec::new(); + + // Convert user-specified format items + for fi in spec_items { + let mut item = ProfileFormatItemResource::new(); + item.format = fi.format; + item.name = fi.name.clone().map(Some); + item.score = Some(fi.score); + if let Some(id) = fi.format { + used_format_ids.push(id); + } + items.push(item); + } + + // Fill remaining custom formats with score=0 + for cf in all_custom_formats { + if let Some(id) = cf.id + && !used_format_ids.contains(&id) + { + let mut item = ProfileFormatItemResource::new(); + item.format = Some(id); + item.score = Some(0); + items.push(item); + } + } + + items +} + +async fn reconcile_cleanup( + profile: Arc, + ctx: Arc, +) -> Result { + let client = &ctx.client; + let namespace = profile + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + + info!( + "Cleaning up SonarrQualityProfile: {}/{}", + namespace, + profile.name_any() + ); + + if let Some(id) = profile.status.as_ref().and_then(|s| s.id) + && let Ok(config) = + get_sonarr_config(&ctx, client, &namespace, &profile.spec.sonarr_instance_ref).await + { + let _ = quality_profile_api::delete_quality_profile(&config, id).await; + } + + Ok(Action::await_change()) +} diff --git a/src/controllers/root_folder.rs b/src/controllers/root_folder.rs new file mode 100644 index 0000000..eee08ba --- /dev/null +++ b/src/controllers/root_folder.rs @@ -0,0 +1,112 @@ +use std::sync::Arc; + +use kube::api::{Api, Patch, PatchParams}; +use kube::runtime::controller::Action; +use kube::{Client, ResourceExt}; +use tracing::info; + +use sonarr::apis::root_folder_api; +use sonarr::models::RootFolderResource; + +use crate::Context; +use crate::crds::{SonarrRootFolder, SonarrRootFolderStatus}; +use crate::error::{Error, Result}; + +use super::tag::get_sonarr_config; +use super::traits::{REQUEUE_DURATION, reconcile_with_finalizer, run_controller}; +use super::{ready_condition, update_conditions}; + +/// Start the SonarrRootFolder controller +pub async fn run(client: Client, context: Arc) { + run_controller::(client, context, "SonarrRootFolder", reconcile).await; +} + +async fn reconcile(obj: Arc, ctx: Arc) -> Result { + reconcile_with_finalizer(obj, ctx, reconcile_apply, reconcile_cleanup).await +} + +async fn reconcile_apply(folder: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = folder + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + let name = folder.name_any(); + + info!("Reconciling SonarrRootFolder: {}/{}", namespace, name); + + let config = + get_sonarr_config(&ctx, client, &namespace, &folder.spec.sonarr_instance_ref).await?; + + // Get or create root folder + let sonarr_folder = if let Some(id) = folder.status.as_ref().and_then(|s| s.id) { + match root_folder_api::get_root_folder_by_id(&config, id).await { + Ok(f) => f, + Err(_) => { + let mut rf = RootFolderResource::new(); + rf.path = Some(Some(folder.spec.path.clone())); + root_folder_api::create_root_folder(&config, Some(rf)).await? + } + } + } else { + let existing = root_folder_api::list_root_folder(&config).await?; + if let Some(existing_folder) = existing + .iter() + .find(|f| f.path.as_ref().and_then(|p| p.as_ref()) == Some(&folder.spec.path)) + { + existing_folder.clone() + } else { + let mut rf = RootFolderResource::new(); + rf.path = Some(Some(folder.spec.path.clone())); + root_folder_api::create_root_folder(&config, Some(rf)).await? + } + }; + + // Update status + let folders_api: Api = Api::namespaced(client.clone(), &namespace); + let mut conditions = folder + .status + .as_ref() + .map(|s| s.conditions.clone()) + .unwrap_or_default(); + update_conditions( + &mut conditions, + ready_condition(true, "Synced", "Root folder synchronized with Sonarr"), + ); + + let status = SonarrRootFolderStatus { + conditions, + id: sonarr_folder.id, + accessible: sonarr_folder.accessible, + free_space: sonarr_folder.free_space.flatten(), + observed_generation: folder.metadata.generation.unwrap_or(0), + }; + + let status_patch = serde_json::json!({ "status": status }); + folders_api + .patch_status(&name, &PatchParams::default(), &Patch::Merge(&status_patch)) + .await?; + + Ok(Action::requeue(REQUEUE_DURATION)) +} + +async fn reconcile_cleanup(folder: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = folder + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + + info!( + "Cleaning up SonarrRootFolder: {}/{}", + namespace, + folder.name_any() + ); + + if let Some(id) = folder.status.as_ref().and_then(|s| s.id) + && let Ok(config) = + get_sonarr_config(&ctx, client, &namespace, &folder.spec.sonarr_instance_ref).await + { + let _ = root_folder_api::delete_root_folder(&config, id).await; + } + + Ok(Action::await_change()) +} diff --git a/src/controllers/series.rs b/src/controllers/series.rs new file mode 100644 index 0000000..5a99672 --- /dev/null +++ b/src/controllers/series.rs @@ -0,0 +1,196 @@ +use std::sync::Arc; + +use kube::api::{Api, Patch, PatchParams}; +use kube::runtime::controller::Action; +use kube::{Client, ResourceExt}; +use tracing::{info, warn}; + +use sonarr::apis::{series_api, series_lookup_api}; +use sonarr::models::{AddSeriesOptions, MonitorTypes, SeriesTypes}; + +use crate::Context; +use crate::crds::series::{MonitorType, SeriesType}; +use crate::crds::{SonarrSeries, SonarrSeriesStatus}; +use crate::error::{Error, Result}; + +use super::tag::get_sonarr_config; +use super::traits::{REQUEUE_DURATION, reconcile_with_finalizer, run_controller}; +use super::{ready_condition, update_conditions}; + +/// Start the SonarrSeries controller +pub async fn run(client: Client, context: Arc) { + run_controller::(client, context, "SonarrSeries", reconcile).await; +} + +async fn reconcile(obj: Arc, ctx: Arc) -> Result { + reconcile_with_finalizer(obj, ctx, reconcile_apply, reconcile_cleanup).await +} + +async fn reconcile_apply(series: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = series + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + let name = series.name_any(); + + info!("Reconciling SonarrSeries: {}/{}", namespace, name); + + let config = + get_sonarr_config(&ctx, client, &namespace, &series.spec.sonarr_instance_ref).await?; + + // Check if series exists by ID or by TVDB ID + let existing_series = if let Some(id) = series.status.as_ref().and_then(|s| s.id) { + series_api::get_series_by_id(&config, id, Some(false)) + .await + .ok() + } else { + // Try to find by TVDB ID + let all_series = + series_api::list_series(&config, Some(series.spec.tvdb_id), Some(true)).await?; + all_series + .into_iter() + .find(|s| s.tvdb_id == Some(series.spec.tvdb_id)) + }; + + let sonarr_series = if let Some(mut existing) = existing_series { + // Update existing series + existing.monitored = Some(series.spec.monitored); + // Use quality_profile.id if available + if let Some(id) = series.spec.quality_profile.id { + existing.quality_profile_id = Some(id); + } + existing.root_folder_path = Some(Some(series.spec.root_folder_path.clone())); + existing.season_folder = Some(series.spec.season_folder); + existing.tags = Some(Some(series.spec.tags.clone())); + existing.series_type = Some(convert_series_type(&series.spec.series_type)); + + series_api::update_series( + &config, + existing.id.unwrap_or(0).to_string().as_str(), + Some(false), + Some(existing), + ) + .await? + } else { + // Need to lookup series from TVDB first + let lookup_results = series_lookup_api::list_series_lookup( + &config, + Some(&format!("tvdb:{}", series.spec.tvdb_id)), + ) + .await?; + + if lookup_results.is_empty() { + return Err(Error::Other(format!( + "Series with TVDB ID {} not found", + series.spec.tvdb_id + ))); + } + + let mut new_series = lookup_results.into_iter().next().unwrap(); + new_series.monitored = Some(series.spec.monitored); + // Use quality_profile.id if available + if let Some(id) = series.spec.quality_profile.id { + new_series.quality_profile_id = Some(id); + } + new_series.root_folder_path = Some(Some(series.spec.root_folder_path.clone())); + new_series.season_folder = Some(series.spec.season_folder); + new_series.tags = Some(Some(series.spec.tags.clone())); + new_series.series_type = Some(convert_series_type(&series.spec.series_type)); + + // Set add options + let mut add_options = AddSeriesOptions::new(); + add_options.monitor = Some(convert_monitor_type(&series.spec.add_options.monitor)); + add_options.search_for_missing_episodes = + Some(series.spec.add_options.search_for_missing_episodes); + add_options.search_for_cutoff_unmet_episodes = + Some(series.spec.add_options.search_for_cutoff_unmet_episodes); + + new_series.add_options = Some(Box::new(add_options)); + + series_api::create_series(&config, Some(new_series)).await? + }; + + // Update status + let series_api_k8s: Api = Api::namespaced(client.clone(), &namespace); + let mut conditions = series + .status + .as_ref() + .map(|s| s.conditions.clone()) + .unwrap_or_default(); + update_conditions( + &mut conditions, + ready_condition(true, "Synced", "Series synchronized with Sonarr"), + ); + + // Extract statistics from the series + let stats = sonarr_series.statistics.as_ref(); + + let status = SonarrSeriesStatus { + conditions, + id: sonarr_series.id, + observed_generation: series.metadata.generation.unwrap_or(0), + episode_count: stats.and_then(|s| s.episode_count), + episode_file_count: stats.and_then(|s| s.episode_file_count), + percent_complete: stats.and_then(|s| s.percent_of_episodes), + next_airing: sonarr_series.next_airing.flatten().map(|d| d.to_string()), + previous_airing: sonarr_series + .previous_airing + .flatten() + .map(|d| d.to_string()), + network: sonarr_series.network.flatten(), + series_status: sonarr_series.status.map(|s| format!("{:?}", s)), + }; + + let status_patch = serde_json::json!({ "status": status }); + series_api_k8s + .patch_status(&name, &PatchParams::default(), &Patch::Merge(&status_patch)) + .await?; + + Ok(Action::requeue(REQUEUE_DURATION)) +} + +fn convert_monitor_type(monitor: &MonitorType) -> MonitorTypes { + match monitor { + MonitorType::All => MonitorTypes::All, + MonitorType::Future => MonitorTypes::Future, + MonitorType::Missing => MonitorTypes::Missing, + MonitorType::Existing => MonitorTypes::Existing, + MonitorType::FirstSeason => MonitorTypes::FirstSeason, + MonitorType::LastSeason => MonitorTypes::LastSeason, + MonitorType::Recent => MonitorTypes::Recent, + MonitorType::Pilot => MonitorTypes::Pilot, + MonitorType::None => MonitorTypes::None, + } +} + +fn convert_series_type(series_type: &SeriesType) -> SeriesTypes { + match series_type { + SeriesType::Standard => SeriesTypes::Standard, + SeriesType::Daily => SeriesTypes::Daily, + SeriesType::Anime => SeriesTypes::Anime, + } +} + +async fn reconcile_cleanup(series: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = series + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + + info!( + "Cleaning up SonarrSeries: {}/{}", + namespace, + series.name_any() + ); + + if let Some(id) = series.status.as_ref().and_then(|s| s.id) + && let Ok(config) = + get_sonarr_config(&ctx, client, &namespace, &series.spec.sonarr_instance_ref).await + { + // Default to not deleting files on cleanup + warn!("Deleting series {} from Sonarr", id); + let _ = series_api::delete_series(&config, id, Some(false), Some(false)).await; + } + + Ok(Action::await_change()) +} diff --git a/src/controllers/sonarr.rs b/src/controllers/sonarr.rs new file mode 100644 index 0000000..0c8b740 --- /dev/null +++ b/src/controllers/sonarr.rs @@ -0,0 +1,1131 @@ +use std::collections::BTreeMap; +use std::sync::Arc; +use std::time::Duration; + +use futures::StreamExt; +use k8s_openapi::api::apps::v1::{Deployment, DeploymentSpec}; +use k8s_openapi::api::core::v1::{ + Container, ContainerPort, EnvVar, PersistentVolumeClaim, PersistentVolumeClaimSpec, PodSpec, + PodTemplateSpec, ResourceRequirements, Secret, Service, ServicePort, ServiceSpec, Volume, + VolumeMount, VolumeResourceRequirements, +}; +use k8s_openapi::api::networking::v1::{ + HTTPIngressPath, HTTPIngressRuleValue, Ingress, IngressBackend, IngressRule, + IngressServiceBackend, IngressSpec, IngressTLS, ServiceBackendPort, +}; +use k8s_openapi::apimachinery::pkg::api::resource::Quantity; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::{LabelSelector, ObjectMeta, OwnerReference}; +use kube::api::{Api, Patch, PatchParams, PostParams}; +use kube::runtime::controller::{Action, Controller}; +use kube::runtime::{ + finalizer::{Event, finalizer}, + watcher, +}; +use kube::{Client, Resource, ResourceExt}; +use tracing::{debug, error, info}; + +use crate::Context; +use crate::crds::{FINALIZER, LABEL_APP, LABEL_INSTANCE, LABEL_MANAGED_BY, Sonarr, SonarrStatus}; +use crate::error::{Error, Result}; + +use super::{progressing_condition, ready_condition, update_conditions}; + +/// Start the Sonarr controller +pub async fn run(client: Client, context: Arc) { + let sonarrs = Api::::all(client.clone()); + + info!("Starting Sonarr controller"); + + Controller::new(sonarrs, watcher::Config::default()) + .shutdown_on_signal() + .run(reconcile, error_policy, context) + .for_each(|res| async move { + match res { + Ok(o) => debug!("Reconciled Sonarr: {:?}", o), + Err(e) => error!("Reconcile error: {:?}", e), + } + }) + .await; +} + +/// Error policy for the controller +fn error_policy(obj: Arc, error: &Error, _ctx: Arc) -> Action { + error!("Error reconciling Sonarr {}: {:?}", obj.name_any(), error); + Action::requeue(Duration::from_secs(60)) +} + +/// Main reconciliation function +async fn reconcile(obj: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = obj + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + let name = obj.name_any(); + + info!("Reconciling Sonarr: {}/{}", namespace, name); + + let instances: Api = Api::namespaced(client.clone(), &namespace); + + // Handle finalizer + finalizer(&instances, FINALIZER, obj.clone(), |event| async { + match event { + Event::Apply(instance) => reconcile_apply(instance, ctx.clone()).await, + Event::Cleanup(instance) => reconcile_cleanup(instance, ctx.clone()).await, + } + }) + .await + .map_err(|e| Error::FinalizerError(Box::new(e))) +} + +/// Reconcile on apply (create/update) +async fn reconcile_apply(instance: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = instance + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + let name = instance.name_any(); + + // Create owner reference + let owner_ref = instance.controller_owner_ref(&()).unwrap(); + + // Create labels + let labels = create_labels(&name); + + // Reconcile PVC + reconcile_pvc(client, &namespace, &instance, &owner_ref, &labels).await?; + + // Reconcile Secret (API key) + reconcile_secret(client, &namespace, &instance, &owner_ref, &labels).await?; + + // Reconcile Deployment + reconcile_deployment(client, &namespace, &instance, &owner_ref, &labels).await?; + + // Reconcile Service + reconcile_service(client, &namespace, &instance, &owner_ref, &labels).await?; + + // Reconcile Ingress (if configured) + if let Some(ref ingress_config) = instance.spec.ingress + && ingress_config.enabled + { + reconcile_ingress( + client, + &namespace, + &instance, + ingress_config, + &owner_ref, + &labels, + ) + .await?; + } + + // Reconcile HTTPRoute (if configured) + if let Some(ref http_route_config) = instance.spec.http_route + && http_route_config.enabled + { + reconcile_http_route( + client, + &namespace, + &instance, + http_route_config, + &owner_ref, + &labels, + ) + .await?; + } + + // Update status and determine requeue interval + let is_ready = update_status(client, &namespace, &instance).await?; + + // Requeue quickly while the deployment is starting, longer once stable + let requeue_secs = if is_ready { 300 } else { 15 }; + Ok(Action::requeue(Duration::from_secs(requeue_secs))) +} + +/// Reconcile on cleanup (delete) +async fn reconcile_cleanup(instance: Arc, _ctx: Arc) -> Result { + info!( + "Cleaning up Sonarr: {}/{}", + instance.namespace().unwrap_or_default(), + instance.name_any() + ); + + // Resources are cleaned up automatically via owner references + Ok(Action::await_change()) +} + +fn create_labels(name: &str) -> BTreeMap { + let mut labels = BTreeMap::new(); + labels.insert(LABEL_APP.to_string(), "sonarr".to_string()); + labels.insert(LABEL_INSTANCE.to_string(), name.to_string()); + labels.insert(LABEL_MANAGED_BY.to_string(), "sonarr-operator".to_string()); + labels +} + +async fn reconcile_pvc( + client: &Client, + namespace: &str, + instance: &Sonarr, + owner_ref: &OwnerReference, + labels: &BTreeMap, +) -> Result<()> { + // Skip if using existing claim + if instance.spec.storage.existing_claim.is_some() { + return Ok(()); + } + + let pvc_name = instance.pvc_name(); + let pvc_api: Api = Api::namespaced(client.clone(), namespace); + + // Check if PVC exists + if pvc_api.get_opt(&pvc_name).await?.is_some() { + debug!("PVC {} already exists", pvc_name); + return Ok(()); + } + + info!("Creating PVC: {}", pvc_name); + + // Use defaults if values are empty + let storage_size = if instance.spec.storage.size.is_empty() { + "1Gi".to_string() + } else { + instance.spec.storage.size.clone() + }; + + let access_modes = if instance.spec.storage.access_modes.is_empty() { + vec!["ReadWriteOnce".to_string()] + } else { + instance.spec.storage.access_modes.clone() + }; + + let mut requests = BTreeMap::new(); + requests.insert("storage".to_string(), Quantity(storage_size)); + + let pvc = PersistentVolumeClaim { + metadata: ObjectMeta { + name: Some(pvc_name.clone()), + namespace: Some(namespace.to_string()), + labels: Some(labels.clone()), + owner_references: Some(vec![owner_ref.clone()]), + ..Default::default() + }, + spec: Some(PersistentVolumeClaimSpec { + access_modes: Some(access_modes), + storage_class_name: instance.spec.storage.storage_class.clone(), + resources: Some(VolumeResourceRequirements { + requests: Some(requests), + ..Default::default() + }), + ..Default::default() + }), + ..Default::default() + }; + + pvc_api + .create(&PostParams::default(), &pvc) + .await + .map_err(Error::KubeError)?; + + Ok(()) +} + +async fn reconcile_secret( + client: &Client, + namespace: &str, + instance: &Sonarr, + owner_ref: &OwnerReference, + labels: &BTreeMap, +) -> Result<()> { + // Skip if using existing secret + if instance.spec.api_key_secret_ref.is_some() { + return Ok(()); + } + + let secret_name = instance.api_key_secret_name(); + let secret_api: Api = Api::namespaced(client.clone(), namespace); + + // Check if secret exists + if secret_api.get_opt(&secret_name).await?.is_some() { + debug!("Secret {} already exists", secret_name); + return Ok(()); + } + + info!("Creating API key secret: {}", secret_name); + + // Generate a random API key + let api_key = generate_api_key(); + + let mut data = BTreeMap::new(); + data.insert( + "api-key".to_string(), + base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &api_key), + ); + + let secret = Secret { + metadata: ObjectMeta { + name: Some(secret_name.clone()), + namespace: Some(namespace.to_string()), + labels: Some(labels.clone()), + owner_references: Some(vec![owner_ref.clone()]), + ..Default::default() + }, + string_data: Some({ + let mut sd = BTreeMap::new(); + sd.insert("api-key".to_string(), api_key); + sd + }), + ..Default::default() + }; + + secret_api + .create(&PostParams::default(), &secret) + .await + .map_err(Error::KubeError)?; + + Ok(()) +} + +fn generate_api_key() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + format!("{:032x}", timestamp) +} + +async fn reconcile_deployment( + client: &Client, + namespace: &str, + instance: &Sonarr, + owner_ref: &OwnerReference, + labels: &BTreeMap, +) -> Result<()> { + let deployment_name = instance.deployment_name(); + let deployment_api: Api = Api::namespaced(client.clone(), namespace); + + let deployment = build_deployment(instance, namespace, owner_ref, labels)?; + + let patch_params = PatchParams::apply("sonarr-operator").force(); + deployment_api + .patch(&deployment_name, &patch_params, &Patch::Apply(&deployment)) + .await + .map_err(Error::KubeError)?; + + info!("Deployment {} applied", deployment_name); + + Ok(()) +} + +fn build_deployment( + instance: &Sonarr, + namespace: &str, + owner_ref: &OwnerReference, + labels: &BTreeMap, +) -> Result { + let deployment_name = instance.deployment_name(); + let pvc_name = instance + .spec + .storage + .existing_claim + .clone() + .unwrap_or_else(|| instance.pvc_name()); + + // Build environment variables + let mut env_vars: Vec = vec![ + EnvVar { + name: "PUID".to_string(), + value: Some("1000".to_string()), + ..Default::default() + }, + EnvVar { + name: "PGID".to_string(), + value: Some("1000".to_string()), + ..Default::default() + }, + EnvVar { + name: "TZ".to_string(), + value: Some("Etc/UTC".to_string()), + ..Default::default() + }, + ]; + + // Add user-defined environment variables + for env in &instance.spec.env { + let env_var = if let Some(ref value) = env.value { + EnvVar { + name: env.name.clone(), + value: Some(value.clone()), + ..Default::default() + } + } else if let Some(ref value_from) = env.value_from { + let mut ev = EnvVar { + name: env.name.clone(), + ..Default::default() + }; + if let Some(ref secret_ref) = value_from.secret_key_ref { + ev.value_from = Some(k8s_openapi::api::core::v1::EnvVarSource { + secret_key_ref: Some(k8s_openapi::api::core::v1::SecretKeySelector { + name: secret_ref.name.clone(), + key: secret_ref.key.clone(), + optional: Some(false), + }), + ..Default::default() + }); + } else if let Some(ref cm_ref) = value_from.config_map_key_ref { + ev.value_from = Some(k8s_openapi::api::core::v1::EnvVarSource { + config_map_key_ref: Some(k8s_openapi::api::core::v1::ConfigMapKeySelector { + name: cm_ref.name.clone(), + key: cm_ref.key.clone(), + optional: Some(false), + }), + ..Default::default() + }); + } + ev + } else { + continue; + }; + env_vars.push(env_var); + } + + // Build volume mounts + let mut volume_mounts: Vec = vec![VolumeMount { + name: "config".to_string(), + mount_path: "/config".to_string(), + ..Default::default() + }]; + + for vm in &instance.spec.volume_mounts { + volume_mounts.push(VolumeMount { + name: vm.name.clone(), + mount_path: vm.mount_path.clone(), + sub_path: vm.sub_path.clone(), + read_only: Some(vm.read_only), + ..Default::default() + }); + } + + // Build volumes + let mut volumes: Vec = vec![Volume { + name: "config".to_string(), + persistent_volume_claim: Some( + k8s_openapi::api::core::v1::PersistentVolumeClaimVolumeSource { + claim_name: pvc_name, + read_only: Some(false), + }, + ), + ..Default::default() + }]; + + for v in &instance.spec.volumes { + let mut volume = Volume { + name: v.name.clone(), + ..Default::default() + }; + if let Some(ref pvc) = v.persistent_volume_claim { + volume.persistent_volume_claim = Some( + k8s_openapi::api::core::v1::PersistentVolumeClaimVolumeSource { + claim_name: pvc.claim_name.clone(), + read_only: Some(pvc.read_only), + }, + ); + } + if let Some(ref hp) = v.host_path { + volume.host_path = Some(k8s_openapi::api::core::v1::HostPathVolumeSource { + path: hp.path.clone(), + type_: hp.host_path_type.clone(), + }); + } + if let Some(ref nfs) = v.nfs { + volume.nfs = Some(k8s_openapi::api::core::v1::NFSVolumeSource { + server: nfs.server.clone(), + path: nfs.path.clone(), + read_only: Some(nfs.read_only), + }); + } + if let Some(ref ed) = v.empty_dir { + volume.empty_dir = Some(k8s_openapi::api::core::v1::EmptyDirVolumeSource { + medium: ed.medium.clone(), + size_limit: ed.size_limit.clone().map(Quantity), + }); + } + volumes.push(volume); + } + + // Determine which secret to use for API key + let api_key_secret_name = if let Some(ref secret_ref) = instance.spec.api_key_secret_ref { + secret_ref.name.clone() + } else { + instance.api_key_secret_name() + }; + let api_key_secret_key = instance + .spec + .api_key_secret_ref + .as_ref() + .map(|s| s.key.clone()) + .unwrap_or_else(|| "api-key".to_string()); + + // Build init containers + let mut init_containers = Vec::new(); + + // Add config initialization init container using sed for XML manipulation + // This container creates/updates config.xml with settings from the operator + let config_script = r#"#!/bin/sh +set -e +CONFIG_FILE="/config/config.xml" +API_KEY="${SONARR_API_KEY}" + +if [ -z "$API_KEY" ]; then + echo "Error: SONARR_API_KEY environment variable is not set" + exit 1 +fi + +# Function to set or update an XML element using sed +set_config_value() { + element="$1" + value="$2" + + if grep -q "<${element}>" "$CONFIG_FILE"; then + # Element exists, update it + sed -i "s|<${element}>.*|<${element}>${value}|g" "$CONFIG_FILE" + echo "Updated ${element}" + else + # Element doesn't exist, add it before + sed -i "s|| <${element}>${value}\n|" "$CONFIG_FILE" + echo "Added ${element}" + fi +} + +if [ ! -f "$CONFIG_FILE" ]; then + # Create minimal config.xml + cat > "$CONFIG_FILE" << 'XMLEOF' + + + info + + * + 8989 + 9898 + False + False + None + Docker + Sonarr + +XMLEOF + echo "Created new config.xml" +fi + +# Set the API key +set_config_value "ApiKey" "$API_KEY" + +# Set additional config values from environment if provided +[ -n "$SONARR_PORT" ] && set_config_value "Port" "$SONARR_PORT" +[ -n "$SONARR_URL_BASE" ] && set_config_value "UrlBase" "$SONARR_URL_BASE" +[ -n "$SONARR_BIND_ADDRESS" ] && set_config_value "BindAddress" "$SONARR_BIND_ADDRESS" +[ -n "$SONARR_LOG_LEVEL" ] && set_config_value "LogLevel" "$SONARR_LOG_LEVEL" +[ -n "$SONARR_INSTANCE_NAME" ] && set_config_value "InstanceName" "$SONARR_INSTANCE_NAME" +[ -n "$SONARR_AUTH_METHOD" ] && set_config_value "AuthenticationMethod" "$SONARR_AUTH_METHOD" +[ -n "$SONARR_AUTH_REQUIRED" ] && set_config_value "AuthenticationRequired" "$SONARR_AUTH_REQUIRED" +[ -n "$SONARR_ANALYTICS" ] && set_config_value "AnalyticsEnabled" "$SONARR_ANALYTICS" + +# Ensure proper permissions +chmod 644 "$CONFIG_FILE" +echo "Config initialization complete" +"#; + + // Build environment variables for init container + let mut init_env = vec![EnvVar { + name: "SONARR_API_KEY".to_string(), + value_from: Some(k8s_openapi::api::core::v1::EnvVarSource { + secret_key_ref: Some(k8s_openapi::api::core::v1::SecretKeySelector { + name: api_key_secret_name.clone(), + key: api_key_secret_key.clone(), + optional: Some(false), + }), + ..Default::default() + }), + ..Default::default() + }]; + + // Add port if non-default + let container_port = instance.container_port(); + if container_port != 8989 { + init_env.push(EnvVar { + name: "SONARR_PORT".to_string(), + value: Some(container_port.to_string()), + ..Default::default() + }); + } + + // Add config options from spec.config + if let Some(ref url_base) = instance.spec.config.url_base { + init_env.push(EnvVar { + name: "SONARR_URL_BASE".to_string(), + value: Some(url_base.clone()), + ..Default::default() + }); + } + if let Some(ref bind_address) = instance.spec.config.bind_address { + init_env.push(EnvVar { + name: "SONARR_BIND_ADDRESS".to_string(), + value: Some(bind_address.clone()), + ..Default::default() + }); + } + if let Some(ref log_level) = instance.spec.config.log_level { + init_env.push(EnvVar { + name: "SONARR_LOG_LEVEL".to_string(), + value: Some(log_level.clone()), + ..Default::default() + }); + } + if let Some(ref instance_name) = instance.spec.config.instance_name { + init_env.push(EnvVar { + name: "SONARR_INSTANCE_NAME".to_string(), + value: Some(instance_name.clone()), + ..Default::default() + }); + } + if let Some(ref auth_method) = instance.spec.config.authentication_method { + init_env.push(EnvVar { + name: "SONARR_AUTH_METHOD".to_string(), + value: Some(auth_method.clone()), + ..Default::default() + }); + } + if let Some(auth_required) = instance.spec.config.authentication_required { + init_env.push(EnvVar { + name: "SONARR_AUTH_REQUIRED".to_string(), + value: Some(if auth_required { "True" } else { "False" }.to_string()), + ..Default::default() + }); + } + if let Some(analytics) = instance.spec.config.analytics_enabled { + init_env.push(EnvVar { + name: "SONARR_ANALYTICS".to_string(), + value: Some(if analytics { "True" } else { "False" }.to_string()), + ..Default::default() + }); + } + + let init_image = instance + .spec + .config + .init_container_image + .clone() + .unwrap_or_else(|| "busybox:latest".to_string()); + + init_containers.push(Container { + name: "init-config".to_string(), + image: Some(init_image), // Busybox with sed (configurable) + command: Some(vec![ + "/bin/sh".to_string(), + "-c".to_string(), + config_script.to_string(), + ]), + env: Some(init_env), + volume_mounts: Some(vec![VolumeMount { + name: "config".to_string(), + mount_path: "/config".to_string(), + ..Default::default() + }]), + ..Default::default() + }); + + // Add user-defined init container if specified + if let Some(ref init_config) = instance.spec.init_container { + let mut init_env = Vec::new(); + for env in &init_config.env { + if let Some(ref value) = env.value { + init_env.push(EnvVar { + name: env.name.clone(), + value: Some(value.clone()), + ..Default::default() + }); + } + } + + init_containers.push(Container { + name: "init-config".to_string(), + image: Some(init_config.image.clone()), + command: if init_config.command.is_empty() { + None + } else { + Some(init_config.command.clone()) + }, + args: if init_config.args.is_empty() { + None + } else { + Some(init_config.args.clone()) + }, + env: if init_env.is_empty() { + None + } else { + Some(init_env) + }, + volume_mounts: Some(vec![VolumeMount { + name: "config".to_string(), + mount_path: "/config".to_string(), + ..Default::default() + }]), + ..Default::default() + }); + } + + // Build main container + let container = Container { + name: "sonarr".to_string(), + image: Some(instance.spec.image.clone()), + image_pull_policy: Some(instance.spec.image_pull_policy.clone()), + ports: Some(vec![ContainerPort { + container_port: instance.container_port(), + name: Some("http".to_string()), + protocol: Some("TCP".to_string()), + ..Default::default() + }]), + env: Some(env_vars), + volume_mounts: Some(volume_mounts), + resources: instance + .spec + .resources + .as_ref() + .map(|r| ResourceRequirements { + limits: if r.limits.is_empty() { + None + } else { + Some( + r.limits + .iter() + .map(|(k, v)| (k.clone(), Quantity(v.clone()))) + .collect(), + ) + }, + requests: if r.requests.is_empty() { + None + } else { + Some( + r.requests + .iter() + .map(|(k, v)| (k.clone(), Quantity(v.clone()))) + .collect(), + ) + }, + ..Default::default() + }), + liveness_probe: Some(k8s_openapi::api::core::v1::Probe { + http_get: Some(k8s_openapi::api::core::v1::HTTPGetAction { + path: Some("/ping".to_string()), + port: k8s_openapi::apimachinery::pkg::util::intstr::IntOrString::Int( + instance.container_port(), + ), + ..Default::default() + }), + initial_delay_seconds: Some(60), + period_seconds: Some(30), + failure_threshold: Some(5), + ..Default::default() + }), + readiness_probe: Some(k8s_openapi::api::core::v1::Probe { + http_get: Some(k8s_openapi::api::core::v1::HTTPGetAction { + path: Some("/ping".to_string()), + port: k8s_openapi::apimachinery::pkg::util::intstr::IntOrString::Int( + instance.container_port(), + ), + ..Default::default() + }), + initial_delay_seconds: Some(10), + period_seconds: Some(10), + ..Default::default() + }), + ..Default::default() + }; + + // Build pod security context + let pod_security_context = instance.spec.security_context.as_ref().map(|sc| { + k8s_openapi::api::core::v1::PodSecurityContext { + run_as_user: sc.run_as_user, + run_as_group: sc.run_as_group, + fs_group: sc.fs_group, + run_as_non_root: sc.run_as_non_root, + ..Default::default() + } + }); + + // Build tolerations + let tolerations: Option> = + if instance.spec.tolerations.is_empty() { + None + } else { + Some( + instance + .spec + .tolerations + .iter() + .map(|t| k8s_openapi::api::core::v1::Toleration { + key: t.key.clone(), + operator: t.operator.clone(), + value: t.value.clone(), + effect: t.effect.clone(), + toleration_seconds: t.toleration_seconds, + }) + .collect(), + ) + }; + + let deployment = Deployment { + metadata: ObjectMeta { + name: Some(deployment_name.clone()), + namespace: Some(namespace.to_string()), + labels: Some(labels.clone()), + owner_references: Some(vec![owner_ref.clone()]), + ..Default::default() + }, + spec: Some(DeploymentSpec { + replicas: Some(instance.spec.replicas), + selector: LabelSelector { + match_labels: Some(labels.clone()), + ..Default::default() + }, + template: PodTemplateSpec { + metadata: Some(ObjectMeta { + labels: Some(labels.clone()), + ..Default::default() + }), + spec: Some(PodSpec { + init_containers: if init_containers.is_empty() { + None + } else { + Some(init_containers) + }, + containers: vec![container], + volumes: Some(volumes), + node_selector: if instance.spec.node_selector.is_empty() { + None + } else { + Some(instance.spec.node_selector.clone()) + }, + tolerations, + security_context: pod_security_context, + ..Default::default() + }), + }, + ..Default::default() + }), + ..Default::default() + }; + + Ok(deployment) +} + +async fn reconcile_service( + client: &Client, + namespace: &str, + instance: &Sonarr, + owner_ref: &OwnerReference, + labels: &BTreeMap, +) -> Result<()> { + let service_name = instance.service_name(); + let service_api: Api = Api::namespaced(client.clone(), namespace); + + let service = Service { + metadata: ObjectMeta { + name: Some(service_name.clone()), + namespace: Some(namespace.to_string()), + labels: Some(labels.clone()), + annotations: if instance.spec.service.annotations.is_empty() { + None + } else { + Some(instance.spec.service.annotations.clone()) + }, + owner_references: Some(vec![owner_ref.clone()]), + ..Default::default() + }, + spec: Some(ServiceSpec { + type_: Some(instance.service_type()), + selector: Some(labels.clone()), + ports: Some(vec![ServicePort { + name: Some("http".to_string()), + port: instance.service_port(), + target_port: Some( + k8s_openapi::apimachinery::pkg::util::intstr::IntOrString::Int( + instance.container_port(), + ), + ), + node_port: instance.spec.service.node_port, + protocol: Some("TCP".to_string()), + ..Default::default() + }]), + ..Default::default() + }), + ..Default::default() + }; + + let patch_params = PatchParams::apply("sonarr-operator").force(); + service_api + .patch(&service_name, &patch_params, &Patch::Apply(&service)) + .await + .map_err(Error::KubeError)?; + + info!("Service {} applied", service_name); + + Ok(()) +} + +async fn reconcile_ingress( + client: &Client, + namespace: &str, + instance: &Sonarr, + ingress_config: &crate::crds::sonarr::IngressConfig, + owner_ref: &OwnerReference, + labels: &BTreeMap, +) -> Result<()> { + let ingress_name = format!("{}-sonarr", instance.name_any()); + let ingress_api: Api = Api::namespaced(client.clone(), namespace); + + let tls = ingress_config.tls.as_ref().map(|tls_config| { + vec![IngressTLS { + hosts: Some(if tls_config.hosts.is_empty() { + vec![ingress_config.host.clone()] + } else { + tls_config.hosts.clone() + }), + secret_name: Some(tls_config.secret_name.clone()), + }] + }); + + let ingress = Ingress { + metadata: ObjectMeta { + name: Some(ingress_name.clone()), + namespace: Some(namespace.to_string()), + labels: Some(labels.clone()), + annotations: if ingress_config.annotations.is_empty() { + None + } else { + Some(ingress_config.annotations.clone()) + }, + owner_references: Some(vec![owner_ref.clone()]), + ..Default::default() + }, + spec: Some(IngressSpec { + ingress_class_name: ingress_config.ingress_class_name.clone(), + tls, + rules: Some(vec![IngressRule { + host: Some(ingress_config.host.clone()), + http: Some(HTTPIngressRuleValue { + paths: vec![HTTPIngressPath { + path: Some(ingress_config.path.clone()), + path_type: ingress_config.path_type.clone(), + backend: IngressBackend { + service: Some(IngressServiceBackend { + name: instance.service_name(), + port: Some(ServiceBackendPort { + number: Some(instance.service_port()), + ..Default::default() + }), + }), + ..Default::default() + }, + }], + }), + }]), + ..Default::default() + }), + ..Default::default() + }; + + let patch_params = PatchParams::apply("sonarr-operator").force(); + ingress_api + .patch(&ingress_name, &patch_params, &Patch::Apply(&ingress)) + .await + .map_err(Error::KubeError)?; + + info!("Ingress {} applied", ingress_name); + + Ok(()) +} + +async fn reconcile_http_route( + client: &Client, + namespace: &str, + instance: &Sonarr, + http_route_config: &crate::crds::sonarr::HTTPRouteConfig, + owner_ref: &OwnerReference, + labels: &BTreeMap, +) -> Result<()> { + use kube::api::DynamicObject; + use kube::discovery::ApiResource; + + let route_name = format!("{}-sonarr", instance.name_any()); + + // Build the HTTPRoute as a DynamicObject since gateway.networking.k8s.io types + // are not in k8s-openapi yet + let api_resource = ApiResource { + group: "gateway.networking.k8s.io".to_string(), + version: "v1".to_string(), + kind: "HTTPRoute".to_string(), + api_version: "gateway.networking.k8s.io/v1".to_string(), + plural: "httproutes".to_string(), + }; + + let route_api: Api = + Api::namespaced_with(client.clone(), namespace, &api_resource); + + // Merge labels + let mut route_labels = labels.clone(); + for (k, v) in &http_route_config.labels { + route_labels.insert(k.clone(), v.clone()); + } + + // Build parent reference + let mut parent_ref = serde_json::json!({ + "group": "gateway.networking.k8s.io", + "kind": "Gateway", + "name": http_route_config.gateway_ref.name + }); + + if let Some(ref ns) = http_route_config.gateway_ref.namespace { + parent_ref["namespace"] = serde_json::json!(ns); + } + if let Some(ref section) = http_route_config.gateway_ref.section_name { + parent_ref["sectionName"] = serde_json::json!(section); + } + + // Build path match + let path_match = serde_json::json!({ + "type": http_route_config.path_type, + "value": http_route_config.path + }); + + // Build backend ref + let backend_ref = serde_json::json!({ + "kind": "Service", + "name": instance.service_name(), + "port": instance.service_port() + }); + + // Build the HTTPRoute spec + let http_route_spec = serde_json::json!({ + "parentRefs": [parent_ref], + "hostnames": if http_route_config.hostnames.is_empty() { None } else { Some(&http_route_config.hostnames) }, + "rules": [{ + "matches": [{ + "path": path_match + }], + "backendRefs": [backend_ref] + }] + }); + + // Build the full HTTPRoute object + let http_route = serde_json::json!({ + "apiVersion": "gateway.networking.k8s.io/v1", + "kind": "HTTPRoute", + "metadata": { + "name": route_name, + "namespace": namespace, + "labels": route_labels, + "annotations": if http_route_config.annotations.is_empty() { None } else { Some(&http_route_config.annotations) }, + "ownerReferences": [owner_ref] + }, + "spec": http_route_spec + }); + + let patch_params = PatchParams::apply("sonarr-operator").force(); + route_api + .patch(&route_name, &patch_params, &Patch::Apply(&http_route)) + .await + .map_err(Error::KubeError)?; + + info!("HTTPRoute {} applied", route_name); + + Ok(()) +} + +async fn update_status(client: &Client, namespace: &str, instance: &Sonarr) -> Result { + let instances: Api = Api::namespaced(client.clone(), namespace); + let name = instance.name_any(); + + // Check deployment status + let deployment_api: Api = Api::namespaced(client.clone(), namespace); + let deployment_name = instance.deployment_name(); + + let (ready, ready_replicas) = match deployment_api.get_opt(&deployment_name).await? { + Some(deployment) => { + let status = deployment.status.unwrap_or_default(); + let ready = status.ready_replicas.unwrap_or(0); + let desired = status.replicas.unwrap_or(0); + (ready >= desired && desired > 0, ready) + } + None => (false, 0), + }; + + // Build URL + let url = if let Some(ref ingress) = instance.spec.ingress { + if ingress.enabled { + let scheme = if ingress.tls.is_some() { + "https" + } else { + "http" + }; + Some(format!("{}://{}{}", scheme, ingress.host, ingress.path)) + } else { + Some(instance.internal_url(namespace)) + } + } else { + Some(instance.internal_url(namespace)) + }; + + // Build conditions + let mut conditions = instance + .status + .as_ref() + .map(|s| s.conditions.clone()) + .unwrap_or_default(); + + if ready { + update_conditions( + &mut conditions, + ready_condition(true, "Ready", "Sonarr instance is ready"), + ); + } else { + update_conditions( + &mut conditions, + ready_condition(false, "NotReady", "Sonarr instance is not ready"), + ); + update_conditions( + &mut conditions, + progressing_condition(true, "Deploying", "Deployment is in progress"), + ); + } + + // Get API key secret name + let api_key_secret = if instance.spec.api_key_secret_ref.is_some() { + instance + .spec + .api_key_secret_ref + .as_ref() + .map(|s| s.name.clone()) + } else { + Some(instance.api_key_secret_name()) + }; + + let status = SonarrStatus { + conditions, + url, + api_key_secret, + observed_generation: instance.metadata.generation.unwrap_or(0), + ready_replicas, + version: None, // TODO: Get from Sonarr API + }; + + let status_patch = serde_json::json!({ + "status": status + }); + + instances + .patch_status(&name, &PatchParams::default(), &Patch::Merge(&status_patch)) + .await + .map_err(Error::KubeError)?; + + Ok(ready) +} diff --git a/src/controllers/tag.rs b/src/controllers/tag.rs new file mode 100644 index 0000000..520103a --- /dev/null +++ b/src/controllers/tag.rs @@ -0,0 +1,186 @@ +use std::sync::Arc; + +use k8s_openapi::api::core::v1::Secret; +use kube::api::{Api, Patch, PatchParams}; +use kube::runtime::controller::Action; +use kube::{Client, ResourceExt}; +use tracing::info; + +use sonarr::apis::configuration::Configuration; +use sonarr::apis::tag_api; +use sonarr::models::TagResource; + +use crate::Context; +use crate::crds::{Sonarr, SonarrInstanceRef, SonarrTag, SonarrTagStatus}; +use crate::error::{Error, Result}; + +use super::traits::{REQUEUE_DURATION, reconcile_with_finalizer, run_controller}; +use super::{ready_condition, update_conditions}; + +/// Start the SonarrTag controller +pub async fn run(client: Client, context: Arc) { + run_controller::(client, context, "SonarrTag", reconcile).await; +} + +async fn reconcile(obj: Arc, ctx: Arc) -> Result { + reconcile_with_finalizer(obj, ctx, reconcile_apply, reconcile_cleanup).await +} + +async fn reconcile_apply(tag: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = tag + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + let name = tag.name_any(); + + info!("Reconciling SonarrTag: {}/{}", namespace, name); + + // Get the Sonarr configuration + let config = get_sonarr_config(&ctx, client, &namespace, &tag.spec.sonarr_instance_ref).await?; + + // Get or create the tag in Sonarr + let sonarr_tag = if let Some(id) = tag.status.as_ref().and_then(|s| s.id) { + // Update existing tag + let mut tag_resource = TagResource::new(); + tag_resource.id = Some(id); + tag_resource.label = Some(Some(tag.spec.label.clone())); + + match tag_api::update_tag(&config, &id.to_string(), Some(tag_resource.clone())).await { + Ok(t) => t, + Err(_) => { + // Tag might have been deleted, create a new one + let mut new_tag = TagResource::new(); + new_tag.label = Some(Some(tag.spec.label.clone())); + tag_api::create_tag(&config, Some(new_tag)).await? + } + } + } else { + // Check if tag already exists + let existing_tags = tag_api::list_tag(&config).await?; + if let Some(existing) = existing_tags + .iter() + .find(|t| t.label.as_ref().and_then(|l| l.as_ref()) == Some(&tag.spec.label)) + { + existing.clone() + } else { + let mut new_tag = TagResource::new(); + new_tag.label = Some(Some(tag.spec.label.clone())); + tag_api::create_tag(&config, Some(new_tag)).await? + } + }; + + // Update status + let tags_api: Api = Api::namespaced(client.clone(), &namespace); + let mut conditions = tag + .status + .as_ref() + .map(|s| s.conditions.clone()) + .unwrap_or_default(); + update_conditions( + &mut conditions, + ready_condition(true, "Synced", "Tag synchronized with Sonarr"), + ); + + let status = SonarrTagStatus { + conditions, + id: sonarr_tag.id, + observed_generation: tag.metadata.generation.unwrap_or(0), + }; + + let status_patch = serde_json::json!({ "status": status }); + tags_api + .patch_status(&name, &PatchParams::default(), &Patch::Merge(&status_patch)) + .await?; + + Ok(Action::requeue(REQUEUE_DURATION)) +} + +async fn reconcile_cleanup(tag: Arc, ctx: Arc) -> Result { + let client = &ctx.client; + let namespace = tag + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + + info!("Cleaning up SonarrTag: {}/{}", namespace, tag.name_any()); + + if let Some(id) = tag.status.as_ref().and_then(|s| s.id) + && let Ok(config) = + get_sonarr_config(&ctx, client, &namespace, &tag.spec.sonarr_instance_ref).await + { + let _ = tag_api::delete_tag(&config, id).await; + } + + Ok(Action::await_change()) +} + +pub async fn get_sonarr_config( + ctx: &Context, + client: &Client, + namespace: &str, + instance_ref: &SonarrInstanceRef, +) -> Result> { + let instance_namespace = instance_ref.namespace.as_deref().unwrap_or(namespace); + let instances: Api = Api::namespaced(client.clone(), instance_namespace); + + let instance = instances + .get(&instance_ref.name) + .await + .map_err(|_| Error::SonarrInstanceNotFound(instance_ref.name.clone()))?; + + // Check if instance is ready + let is_ready = instance + .status + .as_ref() + .map(|s| { + s.conditions + .iter() + .any(|c| c.type_ == "Ready" && c.status == "True") + }) + .unwrap_or(false); + + if !is_ready { + return Err(Error::SonarrInstanceNotReady(instance_ref.name.clone())); + } + + // Get URL + let url = instance + .status + .as_ref() + .and_then(|s| s.url.clone()) + .ok_or(Error::SonarrInstanceNotReady(instance_ref.name.clone()))?; + + // Get API key + let api_key = get_api_key(client, instance_namespace, &instance).await?; + + let instance_key = format!("{}/{}", instance_namespace, instance_ref.name); + Ok(ctx + .sonarr_client_factory + .get_config(&url, &api_key, &instance_key) + .await) +} + +pub async fn get_api_key(client: &Client, namespace: &str, instance: &Sonarr) -> Result { + let secret_name = if let Some(ref secret_ref) = instance.spec.api_key_secret_ref { + secret_ref.name.clone() + } else { + instance.api_key_secret_name() + }; + + let secret_key = instance + .spec + .api_key_secret_ref + .as_ref() + .map(|s| s.key.clone()) + .unwrap_or_else(|| "api-key".to_string()); + + let secrets: Api = Api::namespaced(client.clone(), namespace); + let secret = secrets + .get(&secret_name) + .await + .map_err(|_| Error::MissingApiCredentials)?; + + let data = secret.data.ok_or(Error::MissingApiCredentials)?; + let api_key_bytes = data.get(&secret_key).ok_or(Error::MissingApiCredentials)?; + + String::from_utf8(api_key_bytes.0.clone()).map_err(|_| Error::MissingApiCredentials) +} diff --git a/src/controllers/traits.rs b/src/controllers/traits.rs new file mode 100644 index 0000000..fb2003a --- /dev/null +++ b/src/controllers/traits.rs @@ -0,0 +1,257 @@ +//! Common traits and utilities for Sonarr sub-resource controllers +//! +//! This module provides: +//! - Generic controller run function with built-in finalizer support +//! - Generic error policy +//! - Common status update functions +//! - Shared utilities to reduce boilerplate across controllers + +use std::fmt::Debug; +use std::future::Future; +use std::hash::Hash; +use std::sync::Arc; +use std::time::Duration; + +use futures::StreamExt; +use k8s_openapi::NamespaceResourceScope; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; +use kube::api::{Api, Patch, PatchParams}; +use kube::runtime::controller::{Action, Controller}; +use kube::runtime::finalizer::{Event, finalizer}; +use kube::runtime::watcher; +use kube::{Client, Resource, ResourceExt}; +use serde::{Serialize, de::DeserializeOwned}; +use serde_json::json; +use tracing::{debug, error, info, warn}; + +use crate::Context; +use crate::crds::{FINALIZER, SonarrInstanceRef}; +use crate::error::{Error, Result}; + +use super::{ready_condition, update_conditions}; + +// Re-export the get_sonarr_config from tag module as the canonical implementation +pub use super::tag::get_sonarr_config; + +/// Trait for resource specs that reference a Sonarr instance +pub trait HasSonarrInstanceRef { + fn sonarr_instance_ref(&self) -> &SonarrInstanceRef; +} + +/// Generic error policy for all controllers +pub fn error_policy( + resource_name: &'static str, +) -> impl Fn(Arc, &Error, Arc) -> Action +where + R: Resource + ResourceExt, +{ + move |obj: Arc, err: &Error, _ctx: Arc| { + error!( + "Error reconciling {} {}: {:?}", + resource_name, + obj.name_any(), + err + ); + Action::requeue(Duration::from_secs(60)) + } +} + +/// Wrapper that handles the common finalizer pattern for sub-resources +/// This eliminates boilerplate from individual controllers by wrapping +/// the kube-rs finalizer function with our standard error handling. +/// On apply errors, it writes a failure status condition before propagating. +pub async fn reconcile_with_finalizer( + obj: Arc, + ctx: Arc, + apply_fn: ApplyFn, + cleanup_fn: CleanupFn, +) -> Result +where + R: Resource + + Clone + + Debug + + DeserializeOwned + + Serialize + + Send + + Sync + + 'static, + ApplyFn: FnOnce(Arc, Arc) -> ApplyFut, + ApplyFut: Future> + Send, + CleanupFn: FnOnce(Arc, Arc) -> CleanupFut, + CleanupFut: Future> + Send, +{ + let client = &ctx.client; + let namespace = obj + .namespace() + .ok_or(Error::MissingObjectKey(".metadata.namespace"))?; + + let api: Api = Api::namespaced(client.clone(), &namespace); + let failure_client = client.clone(); + let failure_namespace = namespace.clone(); + + finalizer(&api, FINALIZER, obj.clone(), |event| async { + match event { + Event::Apply(resource) => { + let resource_name = resource.name_any(); + match apply_fn(resource.clone(), ctx.clone()).await { + Ok(action) => Ok(action), + Err(e) => { + // Write failure status so tests/users can see the error + let existing_conditions = extract_conditions(&*resource); + if let Err(status_err) = update_status_failure::( + &failure_client, + &failure_namespace, + &resource_name, + &format!("{}", e), + existing_conditions, + ) + .await + { + warn!( + "Failed to write error status for {}: {:?}", + resource_name, status_err + ); + } + Err(e) + } + } + } + Event::Cleanup(resource) => cleanup_fn(resource, ctx.clone()).await, + } + }) + .await + .map_err(|e| Error::FinalizerError(Box::new(e))) +} + +/// Extract existing conditions from a resource via serde. +/// This allows generic extraction without requiring a trait on each CRD type. +fn extract_conditions(obj: &R) -> Vec { + serde_json::to_value(obj) + .ok() + .and_then(|v| v.get("status")?.get("conditions")?.clone().into()) + .and_then(|c| serde_json::from_value::>(c).ok()) + .unwrap_or_default() +} + +/// Start a generic controller for a Sonarr sub-resource +pub async fn run_controller( + client: Client, + context: Arc, + resource_name: &'static str, + reconcile_fn: ReconcileFn, +) where + R: Resource + Clone + Debug + DeserializeOwned + Send + Sync + 'static, + R::DynamicType: Default + Eq + Hash + Clone, + ReconcileFn: FnMut(Arc, Arc) -> ReconcileFut + Send + Sync + 'static + Clone, + ReconcileFut: Future> + Send + 'static, +{ + let resources = Api::::all(client.clone()); + + info!("Starting {} controller", resource_name); + + let error_handler = error_policy::(resource_name); + + Controller::new(resources, watcher::Config::default()) + .shutdown_on_signal() + .run(reconcile_fn, error_handler, context) + .for_each(|res| async move { + match res { + Ok(o) => debug!("Reconciled {}: {:?}", resource_name, o), + Err(e) => error!("Reconcile error: {:?}", e), + } + }) + .await; +} + +/// Update the status of a Sonarr resource with success +pub async fn update_status_success( + client: &Client, + namespace: &str, + name: &str, + sonarr_id: i32, + generation: i64, + existing_conditions: Vec, +) -> Result<()> +where + R: Resource + + Clone + + Debug + + DeserializeOwned + + Serialize, + R: Resource, +{ + let api: Api = Api::namespaced(client.clone(), namespace); + let mut conditions = existing_conditions; + update_conditions( + &mut conditions, + ready_condition(true, "Synced", "Resource synced with Sonarr"), + ); + + let status = json!({ + "status": { + "conditions": conditions, + "id": sonarr_id, + "observedGeneration": generation + } + }); + + api.patch_status( + name, + &PatchParams::apply("sonarr-operator"), + &Patch::Merge(&status), + ) + .await + .map_err(Error::KubeError)?; + + info!( + "Updated {} {}/{} status with id={}", + std::any::type_name::(), + namespace, + name, + sonarr_id + ); + Ok(()) +} + +/// Update the status of a Sonarr resource with failure +pub async fn update_status_failure( + client: &Client, + namespace: &str, + name: &str, + error_message: &str, + existing_conditions: Vec, +) -> Result<()> +where + R: Resource + + Clone + + Debug + + DeserializeOwned + + Serialize, + R: Resource, +{ + let api: Api = Api::namespaced(client.clone(), namespace); + let mut conditions = existing_conditions; + update_conditions( + &mut conditions, + ready_condition(false, "Error", error_message), + ); + + let status = json!({ + "status": { + "conditions": conditions + } + }); + + api.patch_status( + name, + &PatchParams::apply("sonarr-operator"), + &Patch::Merge(&status), + ) + .await + .map_err(Error::KubeError)?; + + Ok(()) +} + +/// Common requeue duration for successful reconciliation +pub const REQUEUE_DURATION: Duration = Duration::from_secs(300); diff --git a/src/controllers/utils.rs b/src/controllers/utils.rs new file mode 100644 index 0000000..cc207ee --- /dev/null +++ b/src/controllers/utils.rs @@ -0,0 +1,43 @@ +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; + +fn now() -> k8s_openapi::apimachinery::pkg::apis::meta::v1::Time { + k8s_openapi::apimachinery::pkg::apis::meta::v1::Time(k8s_openapi::jiff::Timestamp::now()) +} + +/// Create a Ready condition with the given status and message +pub fn ready_condition(status: bool, reason: &str, message: &str) -> Condition { + Condition { + type_: "Ready".to_string(), + status: if status { "True" } else { "False" }.to_string(), + reason: reason.to_string(), + message: message.to_string(), + last_transition_time: now(), + observed_generation: None, + } +} + +/// Create a Progressing condition +pub fn progressing_condition(status: bool, reason: &str, message: &str) -> Condition { + Condition { + type_: "Progressing".to_string(), + status: if status { "True" } else { "False" }.to_string(), + reason: reason.to_string(), + message: message.to_string(), + last_transition_time: now(), + observed_generation: None, + } +} + +/// Update conditions list, replacing existing conditions of the same type +pub fn update_conditions(conditions: &mut Vec, new_condition: Condition) { + if let Some(existing) = conditions + .iter_mut() + .find(|c| c.type_ == new_condition.type_) + { + if existing.status != new_condition.status || existing.reason != new_condition.reason { + *existing = new_condition; + } + } else { + conditions.push(new_condition); + } +} diff --git a/src/crds/auto_tag.rs b/src/crds/auto_tag.rs new file mode 100644 index 0000000..345220c --- /dev/null +++ b/src/crds/auto_tag.rs @@ -0,0 +1,124 @@ +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::SonarrInstanceRef; + +/// SonarrAutoTag represents an auto-tagging rule configuration in Sonarr +/// Auto-tagging automatically applies tags to series based on conditions +#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[kube( + group = "devopsarr.io", + version = "v1alpha1", + kind = "SonarrAutoTag", + plural = "sonarrautotags", + shortname = "sat", + namespaced, + status = "SonarrAutoTagStatus", + printcolumn = r#"{"name":"Name","type":"string","jsonPath":".spec.name"}"#, + printcolumn = r#"{"name":"RemoveAuto","type":"boolean","jsonPath":".spec.removeTagsAutomatically"}"#, + printcolumn = r#"{"name":"ID","type":"integer","jsonPath":".status.id"}"#, + printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type==\"Ready\")].status"}"#, + printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"# +)] +#[serde(rename_all = "camelCase")] +pub struct SonarrAutoTagSpec { + /// Reference to the SonarrInstance + pub sonarr_instance_ref: SonarrInstanceRef, + + /// Auto-tag rule name + pub name: String, + + /// Remove tags automatically when conditions no longer match + #[serde(default)] + pub remove_tags_automatically: bool, + + /// Tags to apply when conditions match + #[serde(default)] + pub tags: Vec, + + /// Specifications (conditions) for this auto-tag rule + #[serde(default)] + pub specifications: Vec, +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct AutoTagSpecification { + /// Specification name + pub name: String, + + /// Specification type/implementation + pub implementation: AutoTagImplementation, + + /// Negate this condition + #[serde(default)] + pub negate: bool, + + /// This condition is required + #[serde(default = "default_true")] + pub required: bool, + + /// Fields/values for this specification + #[serde(default)] + pub fields: AutoTagFields, +} + +fn default_true() -> bool { + true +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub enum AutoTagImplementation { + /// Root folder path matches + #[default] + RootFolderSpecification, + /// Genre matches + GenreSpecification, + /// Year matches + YearSpecification, + /// Series type matches + SeriesTypeSpecification, + /// Quality profile matches + QualityProfileSpecification, + /// Network matches + NetworkSpecification, + /// Original language matches + OriginalLanguageSpecification, + /// Tags match + TagSpecification, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct AutoTagFields { + /// Value for the specification (path, genre, network, etc.) + #[serde(default)] + pub value: Option, + + /// Minimum value (for year specifications) + #[serde(default)] + pub min: Option, + + /// Maximum value (for year specifications) + #[serde(default)] + pub max: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SonarrAutoTagStatus { + /// Current conditions + #[serde(default)] + pub conditions: Vec, + + /// Sonarr Auto Tag ID + #[serde(default)] + pub id: Option, + + /// Observed generation + #[serde(default)] + pub observed_generation: i64, +} diff --git a/src/crds/custom_format.rs b/src/crds/custom_format.rs new file mode 100644 index 0000000..d4990e8 --- /dev/null +++ b/src/crds/custom_format.rs @@ -0,0 +1,121 @@ +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::SonarrInstanceRef; + +/// SonarrCustomFormat represents a custom format configuration in Sonarr +/// Custom formats are used to score releases based on various criteria +#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[kube( + group = "devopsarr.io", + version = "v1alpha1", + kind = "SonarrCustomFormat", + plural = "sonarrcustomformats", + shortname = "scf", + namespaced, + status = "SonarrCustomFormatStatus", + printcolumn = r#"{"name":"Name","type":"string","jsonPath":".spec.name"}"#, + printcolumn = r#"{"name":"ID","type":"integer","jsonPath":".status.id"}"#, + printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type==\"Ready\")].status"}"#, + printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"# +)] +#[serde(rename_all = "camelCase")] +pub struct SonarrCustomFormatSpec { + /// Reference to the SonarrInstance + pub sonarr_instance_ref: SonarrInstanceRef, + + /// Custom format name + pub name: String, + + /// Include custom format name when renaming files + #[serde(default)] + pub include_custom_format_when_renaming: bool, + + /// Specifications (conditions) for this custom format + #[serde(default)] + pub specifications: Vec, +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct CustomFormatSpecification { + /// Specification name + pub name: String, + + /// Specification type/implementation + pub implementation: CustomFormatImplementation, + + /// Negate this condition + #[serde(default)] + pub negate: bool, + + /// This condition is required + #[serde(default = "default_true")] + pub required: bool, + + /// Fields/values for this specification + #[serde(default)] + pub fields: CustomFormatFields, +} + +fn default_true() -> bool { + true +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub enum CustomFormatImplementation { + /// Release title matches regex + #[default] + ReleaseTitleSpecification, + /// Source matches + SourceSpecification, + /// Resolution matches + ResolutionSpecification, + /// Quality modifier matches + QualityModifierSpecification, + /// Size specification + SizeSpecification, + /// Indexer flag + IndexerFlagSpecification, + /// Language matches + LanguageSpecification, + /// Release group matches + ReleaseGroupSpecification, + /// Edition matches + EditionSpecification, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct CustomFormatFields { + /// Value for the specification (regex pattern, source type, etc.) + #[serde(default)] + pub value: Option, + + /// Minimum value (for size specifications) + #[serde(default)] + pub min: Option, + + /// Maximum value (for size specifications) + #[serde(default)] + pub max: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SonarrCustomFormatStatus { + /// Current conditions + #[serde(default)] + pub conditions: Vec, + + /// Sonarr Custom Format ID + #[serde(default)] + pub id: Option, + + /// Observed generation + #[serde(default)] + pub observed_generation: i64, +} diff --git a/src/crds/delay_profile.rs b/src/crds/delay_profile.rs new file mode 100644 index 0000000..7c5b5b7 --- /dev/null +++ b/src/crds/delay_profile.rs @@ -0,0 +1,98 @@ +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::SonarrInstanceRef; + +/// SonarrDelayProfile represents a delay profile configuration in Sonarr +/// Delay profiles control how long Sonarr waits before grabbing a release +#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[kube( + group = "devopsarr.io", + version = "v1alpha1", + kind = "SonarrDelayProfile", + plural = "sonarrdelayprofiles", + shortname = "sdp", + namespaced, + status = "SonarrDelayProfileStatus", + printcolumn = r#"{"name":"Protocol","type":"string","jsonPath":".spec.preferredProtocol"}"#, + printcolumn = r#"{"name":"UsenetDelay","type":"integer","jsonPath":".spec.usenetDelay"}"#, + printcolumn = r#"{"name":"TorrentDelay","type":"integer","jsonPath":".spec.torrentDelay"}"#, + printcolumn = r#"{"name":"ID","type":"integer","jsonPath":".status.id"}"#, + printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type==\"Ready\")].status"}"#, + printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"# +)] +#[serde(rename_all = "camelCase")] +pub struct SonarrDelayProfileSpec { + /// Reference to the SonarrInstance + pub sonarr_instance_ref: SonarrInstanceRef, + + /// Enable Usenet downloads + #[serde(default = "default_true")] + pub enable_usenet: bool, + + /// Enable Torrent downloads + #[serde(default = "default_true")] + pub enable_torrent: bool, + + /// Preferred download protocol + #[serde(default)] + pub preferred_protocol: DownloadProtocol, + + /// Delay for Usenet in minutes + #[serde(default)] + pub usenet_delay: i32, + + /// Delay for Torrents in minutes + #[serde(default)] + pub torrent_delay: i32, + + /// Bypass delay if highest quality + #[serde(default)] + pub bypass_if_highest_quality: bool, + + /// Bypass delay if above custom format score + #[serde(default)] + pub bypass_if_above_custom_format_score: bool, + + /// Minimum custom format score to bypass delay + #[serde(default)] + pub minimum_custom_format_score: i32, + + /// Order of this profile (lower = higher priority) + #[serde(default)] + pub order: i32, + + /// Tags to apply this delay profile to + #[serde(default)] + pub tags: Vec, +} + +fn default_true() -> bool { + true +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub enum DownloadProtocol { + #[default] + Usenet, + Torrent, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SonarrDelayProfileStatus { + /// Current conditions + #[serde(default)] + pub conditions: Vec, + + /// Sonarr Delay Profile ID + #[serde(default)] + pub id: Option, + + /// Observed generation + #[serde(default)] + pub observed_generation: i64, +} diff --git a/src/crds/download_client.rs b/src/crds/download_client.rs new file mode 100644 index 0000000..a24730b --- /dev/null +++ b/src/crds/download_client.rs @@ -0,0 +1,203 @@ +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::SecretKeySelector; +use super::SonarrInstanceRef; + +/// SonarrDownloadClient represents a download client configuration in Sonarr +/// Download clients are used to download releases (qBittorrent, Transmission, SABnzbd, etc.) +#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[kube( + group = "devopsarr.io", + version = "v1alpha1", + kind = "SonarrDownloadClient", + plural = "sonarrdownloadclients", + shortname = "sdc", + namespaced, + status = "SonarrDownloadClientStatus", + printcolumn = r#"{"name":"Name","type":"string","jsonPath":".spec.name"}"#, + printcolumn = r#"{"name":"Type","type":"string","jsonPath":".spec.downloadClientType"}"#, + printcolumn = r#"{"name":"ID","type":"integer","jsonPath":".status.id"}"#, + printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type==\"Ready\")].status"}"#, + printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"# +)] +#[serde(rename_all = "camelCase")] +pub struct SonarrDownloadClientSpec { + /// Reference to the SonarrInstance + pub sonarr_instance_ref: SonarrInstanceRef, + + /// Download client name + pub name: String, + + /// Enable this download client + #[serde(default = "default_true")] + pub enable: bool, + + /// Download client type + pub download_client_type: DownloadClientType, + + /// Priority for this download client + #[serde(default = "default_priority")] + pub priority: i32, + + /// Remove completed downloads + #[serde(default = "default_true")] + pub remove_completed_downloads: bool, + + /// Remove failed downloads + #[serde(default = "default_true")] + pub remove_failed_downloads: bool, + + /// Tags for this download client + #[serde(default)] + pub tags: Vec, + + /// Download client configuration + pub config: DownloadClientConfig, +} + +fn default_true() -> bool { + true +} + +fn default_priority() -> i32 { + 1 +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "PascalCase")] +pub enum DownloadClientType { + Aria2, + Deluge, + Flood, + Hadouken, + #[serde(rename = "Nzbget")] + NzbGet, + #[serde(rename = "Nzbvortex")] + NzbVortex, + Pneumatic, + #[serde(rename = "QBittorrent")] + QBittorrent, + #[serde(rename = "RTorrent")] + RTorrent, + #[serde(rename = "Sabnzbd")] + SABnzbd, + TorrentBlackhole, + TorrentDownloadStation, + Transmission, + UsenetBlackhole, + UsenetDownloadStation, + #[serde(rename = "UTorrent")] + UTorrent, + Vuze, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct DownloadClientConfig { + /// Host address + #[serde(default)] + pub host: Option, + + /// Port number + #[serde(default)] + pub port: Option, + + /// Use SSL + #[serde(default)] + pub use_ssl: bool, + + /// URL base path + #[serde(default)] + pub url_base: Option, + + /// Username + #[serde(default)] + pub username: Option, + + /// Password from secret + #[serde(default)] + pub password_secret_ref: Option, + + /// API key from secret (for some clients) + #[serde(default)] + pub api_key_secret_ref: Option, + + /// TV category + #[serde(default)] + pub tv_category: Option, + + /// TV directory + #[serde(default)] + pub tv_directory: Option, + + /// Recent TV priority (0 = Last, 1 = First) + #[serde(default)] + pub recent_tv_priority: Option, + + /// Older TV priority (0 = Last, 1 = First) + #[serde(default)] + pub older_tv_priority: Option, + + /// Add paused + #[serde(default)] + pub add_paused: bool, + + /// Save magnet files (for blackhole) + #[serde(default)] + pub save_magnet_files: bool, + + /// Watch folder (for blackhole) + #[serde(default)] + pub watch_folder: Option, + + /// Torrent folder (for blackhole) + #[serde(default)] + pub torrent_folder: Option, + + /// NZB folder (for blackhole) + #[serde(default)] + pub nzb_folder: Option, + + /// Strm folder (for pneumatic) + #[serde(default)] + pub strm_folder: Option, + + /// Secret token (for Aria2) + #[serde(default)] + pub secret_token_secret_ref: Option, + + /// RPC path (for Aria2) + #[serde(default)] + pub rpc_path: Option, + + /// Initial state (for qBittorrent: 0 = Start, 1 = ForceStart, 2 = Pause) + #[serde(default)] + pub initial_state: Option, + + /// Sequential order (for qBittorrent) + #[serde(default)] + pub sequential_order: bool, + + /// First and last (for qBittorrent) + #[serde(default)] + pub first_and_last: bool, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SonarrDownloadClientStatus { + /// Current conditions + #[serde(default)] + pub conditions: Vec, + + /// Sonarr Download Client ID + #[serde(default)] + pub id: Option, + + /// Observed generation + #[serde(default)] + pub observed_generation: i64, +} diff --git a/src/crds/download_client_config.rs b/src/crds/download_client_config.rs new file mode 100644 index 0000000..a749093 --- /dev/null +++ b/src/crds/download_client_config.rs @@ -0,0 +1,62 @@ +//! SonarrDownloadClientConfig CRD +//! +//! Configures global download client settings for a Sonarr instance. +//! Only one resource per Sonarr instance is allowed. + +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::SonarrInstanceRef; + +/// SonarrDownloadClientConfig configures global download client settings for a Sonarr instance. +/// Only one SonarrDownloadClientConfig per Sonarr instance is allowed. +/// Note: This is different from SonarrDownloadClient which configures individual download clients. +#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema, Default)] +#[kube( + group = "devopsarr.io", + version = "v1alpha1", + kind = "SonarrDownloadClientConfig", + plural = "sonarrdownloadclientconfigs", + shortname = "sdcc", + namespaced, + status = "SonarrDownloadClientConfigStatus", + printcolumn = r#"{"name":"Instance","type":"string","jsonPath":".spec.sonarrInstanceRef.name"}"#, + printcolumn = r#"{"name":"Completed Handling","type":"boolean","jsonPath":".spec.enableCompletedDownloadHandling"}"#, + printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type==\"Ready\")].status"}"#, + printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"# +)] +#[serde(rename_all = "camelCase")] +pub struct SonarrDownloadClientConfigSpec { + /// Reference to the Sonarr instance + pub sonarr_instance_ref: SonarrInstanceRef, + + /// Working folders for download client (container path mapping) + #[serde(default)] + pub download_client_working_folders: Option, + + /// Enable completed download handling + #[serde(default)] + pub enable_completed_download_handling: Option, + + /// Automatically redownload failed releases + #[serde(default)] + pub auto_redownload_failed: Option, + + /// Automatically redownload failed releases from interactive search + #[serde(default)] + pub auto_redownload_failed_from_interactive_search: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SonarrDownloadClientConfigStatus { + /// Current conditions + #[serde(default)] + pub conditions: Vec, + + /// Observed generation + #[serde(default)] + pub observed_generation: i64, +} diff --git a/src/crds/import_list.rs b/src/crds/import_list.rs new file mode 100644 index 0000000..d91a1f7 --- /dev/null +++ b/src/crds/import_list.rs @@ -0,0 +1,204 @@ +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::SonarrInstanceRef; + +/// SonarrImportList represents an import list configuration in Sonarr +/// Import lists automatically add series from external sources (Trakt, Plex, etc.) +#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[kube( + group = "devopsarr.io", + version = "v1alpha1", + kind = "SonarrImportList", + plural = "sonarrimportlists", + shortname = "sil", + namespaced, + status = "SonarrImportListStatus", + printcolumn = r#"{"name":"Name","type":"string","jsonPath":".spec.name"}"#, + printcolumn = r#"{"name":"Type","type":"string","jsonPath":".spec.listType"}"#, + printcolumn = r#"{"name":"ID","type":"integer","jsonPath":".status.id"}"#, + printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type==\"Ready\")].status"}"#, + printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"# +)] +#[serde(rename_all = "camelCase")] +pub struct SonarrImportListSpec { + /// Reference to the SonarrInstance + pub sonarr_instance_ref: SonarrInstanceRef, + + /// Import list name + pub name: String, + + /// Import list type/implementation + pub list_type: ImportListType, + + /// Enable automatic add + #[serde(default = "default_true")] + pub enable_automatic_add: bool, + + /// Search for missing episodes when adding + #[serde(default)] + pub search_for_missing_episodes: bool, + + /// Monitor type for imported series + #[serde(default)] + pub should_monitor: MonitorTypes, + + /// Monitor new items + #[serde(default)] + pub monitor_new_items: NewItemMonitorTypes, + + /// Root folder path for imported series + pub root_folder_path: String, + + /// Quality profile ID to use + pub quality_profile_id: i32, + + /// Series type + #[serde(default)] + pub series_type: SeriesTypes, + + /// Use season folders + #[serde(default = "default_true")] + pub season_folder: bool, + + /// List order + #[serde(default)] + pub list_order: i32, + + /// Tags for imported series + #[serde(default)] + pub tags: Vec, + + /// Import list configuration + #[serde(default)] + pub config: ImportListConfig, +} + +fn default_true() -> bool { + true +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub enum ImportListType { + /// Sonarr Import List + #[default] + SonarrImport, + /// Trakt List + TraktListImport, + /// Trakt User + TraktUserImport, + /// Trakt Popular + TraktPopularImport, + /// Plex Watchlist + PlexImport, + /// IMDb Lists + ImdbListImport, + /// Custom List + CustomImport, + /// Simkl + SimklImport, + /// AniList + AniListImport, + /// MyAnimeList + MyAnimeListImport, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub enum MonitorTypes { + #[default] + All, + Future, + Missing, + Existing, + FirstSeason, + LatestSeason, + Pilot, + MonitorSpecials, + UnmonitorSpecials, + None, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub enum NewItemMonitorTypes { + #[default] + All, + None, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub enum SeriesTypes { + #[default] + Standard, + Daily, + Anime, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ImportListConfig { + /// Base URL (for Sonarr import) + #[serde(default)] + pub base_url: Option, + + /// API key (for Sonarr import) + #[serde(default)] + pub api_key: Option, + + /// Access token (for Trakt/Plex) + #[serde(default)] + pub access_token: Option, + + /// Username (for various services) + #[serde(default)] + pub username: Option, + + /// Auth user (for Trakt) + #[serde(default)] + pub auth_user: Option, + + /// List name/ID + #[serde(default)] + pub listname: Option, + + /// List ID + #[serde(default)] + pub list_id: Option, + + /// Trakt list type + #[serde(default)] + pub trakt_list_type: Option, + + /// Language profile ID (deprecated in v4) + #[serde(default)] + pub language_profile_id: Option, + + /// Profile IDs (for Sonarr import) + #[serde(default)] + pub profile_ids: Vec, + + /// Tag IDs (for Sonarr import) + #[serde(default)] + pub tag_ids: Vec, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SonarrImportListStatus { + /// Current conditions + #[serde(default)] + pub conditions: Vec, + + /// Sonarr Import List ID + #[serde(default)] + pub id: Option, + + /// Observed generation + #[serde(default)] + pub observed_generation: i64, +} diff --git a/src/crds/indexer.rs b/src/crds/indexer.rs new file mode 100644 index 0000000..19450eb --- /dev/null +++ b/src/crds/indexer.rs @@ -0,0 +1,166 @@ +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::SecretKeySelector; +use super::SonarrInstanceRef; + +/// SonarrIndexer represents an indexer configuration in Sonarr +/// Indexers are sources for finding releases (Newznab, Torznab, etc.) +#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[kube( + group = "devopsarr.io", + version = "v1alpha1", + kind = "SonarrIndexer", + plural = "sonarrindexers", + shortname = "sidx", + namespaced, + status = "SonarrIndexerStatus", + printcolumn = r#"{"name":"Name","type":"string","jsonPath":".spec.name"}"#, + printcolumn = r#"{"name":"Type","type":"string","jsonPath":".spec.indexerType"}"#, + printcolumn = r#"{"name":"ID","type":"integer","jsonPath":".status.id"}"#, + printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type==\"Ready\")].status"}"#, + printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"# +)] +#[serde(rename_all = "camelCase")] +pub struct SonarrIndexerSpec { + /// Reference to the SonarrInstance + pub sonarr_instance_ref: SonarrInstanceRef, + + /// Indexer name + pub name: String, + + /// Indexer type (Newznab, Torznab, etc.) + pub indexer_type: IndexerType, + + /// Enable RSS feeds + #[serde(default = "default_true")] + pub enable_rss: bool, + + /// Enable automatic search + #[serde(default = "default_true")] + pub enable_automatic_search: bool, + + /// Enable interactive search + #[serde(default = "default_true")] + pub enable_interactive_search: bool, + + /// Priority for this indexer + #[serde(default = "default_priority")] + pub priority: i32, + + /// Download client ID to use + #[serde(default)] + pub download_client_id: Option, + + /// Tags for this indexer + #[serde(default)] + pub tags: Vec, + + /// Indexer-specific configuration + pub config: IndexerConfig, +} + +fn default_true() -> bool { + true +} + +fn default_priority() -> i32 { + 25 +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum IndexerType { + Newznab, + Torznab, + Fanzub, + BroadcastheNet, + FileList, + HDBits, + IPTorrents, + Nyaa, + TorrentRss, + TorrentLeech, +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct IndexerConfig { + /// Base URL for the indexer + #[serde(default)] + pub base_url: Option, + + /// API path (default: /api) + #[serde(default)] + pub api_path: Option, + + /// API key (can reference a secret) + #[serde(default)] + pub api_key: Option, + + /// API key from secret reference + #[serde(default)] + pub api_key_secret_ref: Option, + + /// Categories to search + #[serde(default)] + pub categories: Vec, + + /// Anime categories + #[serde(default)] + pub anime_categories: Vec, + + /// Search anime in standard format + #[serde(default)] + pub anime_standard_format_search: bool, + + /// Additional parameters + #[serde(default)] + pub additional_parameters: Option, + + /// Minimum seeders (for torrent indexers) + #[serde(default)] + pub minimum_seeders: Option, + + /// Seed ratio (for torrent indexers) + #[serde(default)] + pub seed_ratio: Option, + + /// Seed time (for torrent indexers) + #[serde(default)] + pub seed_time: Option, + + /// Cookie (for some indexers) + #[serde(default)] + pub cookie: Option, + + /// Username (for some indexers) + #[serde(default)] + pub username: Option, + + /// Password secret reference (for some indexers) + #[serde(default)] + pub password_secret_ref: Option, + + /// Passkey (for some indexers) + #[serde(default)] + pub passkey: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SonarrIndexerStatus { + /// Current conditions + #[serde(default)] + pub conditions: Vec, + + /// Sonarr Indexer ID + #[serde(default)] + pub id: Option, + + /// Observed generation + #[serde(default)] + pub observed_generation: i64, +} diff --git a/src/crds/indexer_config.rs b/src/crds/indexer_config.rs new file mode 100644 index 0000000..5f06971 --- /dev/null +++ b/src/crds/indexer_config.rs @@ -0,0 +1,62 @@ +//! SonarrIndexerConfig CRD +//! +//! Configures global indexer settings for a Sonarr instance. +//! Only one resource per Sonarr instance is allowed. + +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::SonarrInstanceRef; + +/// SonarrIndexerConfig configures global indexer settings for a Sonarr instance. +/// Only one SonarrIndexerConfig per Sonarr instance is allowed. +/// Note: This is different from SonarrIndexer which configures individual indexers. +#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema, Default)] +#[kube( + group = "devopsarr.io", + version = "v1alpha1", + kind = "SonarrIndexerConfig", + plural = "sonarrindexerconfigs", + shortname = "sic", + namespaced, + status = "SonarrIndexerConfigStatus", + printcolumn = r#"{"name":"Instance","type":"string","jsonPath":".spec.sonarrInstanceRef.name"}"#, + printcolumn = r#"{"name":"RSS Interval","type":"integer","jsonPath":".spec.rssSyncInterval"}"#, + printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type==\"Ready\")].status"}"#, + printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"# +)] +#[serde(rename_all = "camelCase")] +pub struct SonarrIndexerConfigSpec { + /// Reference to the Sonarr instance + pub sonarr_instance_ref: SonarrInstanceRef, + + /// Minimum age in minutes before downloading (usenet) + #[serde(default)] + pub minimum_age: Option, + + /// Retention in days (0 = unlimited) + #[serde(default)] + pub retention: Option, + + /// Maximum release size in MB (0 = unlimited) + #[serde(default)] + pub maximum_size: Option, + + /// RSS sync interval in minutes (0 = disabled, minimum 10) + #[serde(default)] + pub rss_sync_interval: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SonarrIndexerConfigStatus { + /// Current conditions + #[serde(default)] + pub conditions: Vec, + + /// Observed generation + #[serde(default)] + pub observed_generation: i64, +} diff --git a/src/crds/language_profile.rs b/src/crds/language_profile.rs new file mode 100644 index 0000000..488ceae --- /dev/null +++ b/src/crds/language_profile.rs @@ -0,0 +1,133 @@ +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::SonarrInstanceRef; + +/// SonarrLanguageProfile represents a language profile configuration in Sonarr +/// Language profiles define preferred languages for downloading series +/// Note: Deprecated in Sonarr v4, replaced by per-series language selection +#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[kube( + group = "devopsarr.io", + version = "v1alpha1", + kind = "SonarrLanguageProfile", + plural = "sonarrlanguageprofiles", + shortname = "slp", + namespaced, + status = "SonarrLanguageProfileStatus", + printcolumn = r#"{"name":"Name","type":"string","jsonPath":".spec.name"}"#, + printcolumn = r#"{"name":"Cutoff","type":"string","jsonPath":".spec.cutoffLanguage"}"#, + printcolumn = r#"{"name":"ID","type":"integer","jsonPath":".status.id"}"#, + printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type==\"Ready\")].status"}"#, + printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"# +)] +#[serde(rename_all = "camelCase")] +pub struct SonarrLanguageProfileSpec { + /// Reference to the SonarrInstance + pub sonarr_instance_ref: SonarrInstanceRef, + + /// Language profile name + pub name: String, + + /// Allow upgrades to better quality languages + #[serde(default)] + pub upgrade_allowed: bool, + + /// Cutoff language - stop upgrading when this language is reached + pub cutoff_language: LanguageType, + + /// Ordered list of languages (first = highest priority) + pub languages: Vec, +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct LanguageItem { + /// Language + pub language: LanguageType, + + /// Whether this language is allowed + #[serde(default = "default_true")] + pub allowed: bool, +} + +fn default_true() -> bool { + true +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "PascalCase")] +pub enum LanguageType { + Unknown, + #[default] + English, + French, + Spanish, + German, + Italian, + Danish, + Dutch, + Japanese, + Icelandic, + Chinese, + Russian, + Polish, + Vietnamese, + Swedish, + Norwegian, + Finnish, + Turkish, + Portuguese, + Flemish, + Greek, + Korean, + Hungarian, + Hebrew, + Lithuanian, + Czech, + Hindi, + Romanian, + Thai, + Bulgarian, + #[serde(rename = "PortugueseBrazil")] + PortugueseBrazil, + Arabic, + Ukrainian, + Persian, + Bengali, + Slovak, + Latvian, + #[serde(rename = "SpanishLatino")] + SpanishLatino, + Catalan, + Croatian, + Serbian, + Bosnian, + Estonian, + Tamil, + Indonesian, + Telugu, + Macedonian, + Slovenian, + Malay, + Original, + Any, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SonarrLanguageProfileStatus { + /// Current conditions + #[serde(default)] + pub conditions: Vec, + + /// Sonarr Language Profile ID + #[serde(default)] + pub id: Option, + + /// Observed generation + #[serde(default)] + pub observed_generation: i64, +} diff --git a/src/crds/media_management_config.rs b/src/crds/media_management_config.rs new file mode 100644 index 0000000..d9f1ff4 --- /dev/null +++ b/src/crds/media_management_config.rs @@ -0,0 +1,160 @@ +//! SonarrMediaManagementConfig CRD +//! +//! Configures media management settings for a Sonarr instance. +//! Only one resource per Sonarr instance is allowed. + +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::SonarrInstanceRef; + +/// SonarrMediaManagementConfig configures media management settings for a Sonarr instance. +/// Only one SonarrMediaManagementConfig per Sonarr instance is allowed. +#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema, Default)] +#[kube( + group = "devopsarr.io", + version = "v1alpha1", + kind = "SonarrMediaManagementConfig", + plural = "sonarrmediamanagementconfigs", + shortname = "smmc", + namespaced, + status = "SonarrMediaManagementConfigStatus", + printcolumn = r#"{"name":"Instance","type":"string","jsonPath":".spec.sonarrInstanceRef.name"}"#, + printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type==\"Ready\")].status"}"#, + printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"# +)] +#[serde(rename_all = "camelCase")] +pub struct SonarrMediaManagementConfigSpec { + /// Reference to the Sonarr instance + pub sonarr_instance_ref: SonarrInstanceRef, + + /// Auto unmonitor previously downloaded episodes when marked as deleted + #[serde(default)] + pub auto_unmonitor_previously_downloaded_episodes: Option, + + /// Recycle bin path (empty to disable) + #[serde(default)] + pub recycle_bin: Option, + + /// Days to keep files in recycle bin before cleaning (0 to disable) + #[serde(default)] + pub recycle_bin_cleanup_days: Option, + + /// Download propers and repacks: DoNotPrefer, PreferAndUpgrade, DoNotUpgrade + #[serde(default, skip_serializing_if = "Option::is_none")] + pub download_propers_and_repacks: Option, + + /// Create empty series folders during disk scan + #[serde(default)] + pub create_empty_series_folders: Option, + + /// Delete empty series and season folders during disk scan + #[serde(default)] + pub delete_empty_folders: Option, + + /// File date to use: None, LocalAirDate, UtcAirDate + #[serde(default, skip_serializing_if = "Option::is_none")] + pub file_date: Option, + + /// Rescan series folder after refresh: Always, AfterManual, Never + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rescan_after_refresh: Option, + + /// Set permissions on Linux/macOS + #[serde(default)] + pub set_permissions_linux: Option, + + /// chmod folder permissions (e.g., "755") + #[serde(default)] + pub chmod_folder: Option, + + /// chown group + #[serde(default)] + pub chown_group: Option, + + /// Episode title required: Always, BulkSeasonReleases, Never + #[serde(default, skip_serializing_if = "Option::is_none")] + pub episode_title_required: Option, + + /// Skip free space check when importing + #[serde(default)] + pub skip_free_space_check_when_importing: Option, + + /// Minimum free space when importing (MB) + #[serde(default)] + pub minimum_free_space_when_importing: Option, + + /// Use hardlinks instead of copy when possible + #[serde(default)] + pub copy_using_hardlinks: Option, + + /// Use script for importing + #[serde(default)] + pub use_script_import: Option, + + /// Script import path + #[serde(default)] + pub script_import_path: Option, + + /// Import extra files (subtitles, etc.) + #[serde(default)] + pub import_extra_files: Option, + + /// Extra file extensions to import (e.g., "srt,sub") + #[serde(default)] + pub extra_file_extensions: Option, + + /// Enable media info scanning + #[serde(default)] + pub enable_media_info: Option, +} + +/// How to handle propers and repacks +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema, PartialEq)] +pub enum ProperDownloadType { + #[default] + DoNotPrefer, + PreferAndUpgrade, + DoNotUpgrade, +} + +/// File date type to use +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema, PartialEq)] +pub enum FileDateType { + #[default] + None, + LocalAirDate, + UtcAirDate, +} + +/// When to rescan after refresh +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema, PartialEq)] +pub enum RescanAfterRefreshType { + #[default] + Always, + AfterManual, + Never, +} + +/// When episode title is required +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema, PartialEq)] +pub enum EpisodeTitleRequiredType { + #[default] + Always, + BulkSeasonReleases, + Never, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SonarrMediaManagementConfigStatus { + /// Current conditions + #[serde(default)] + pub conditions: Vec, + + /// Observed generation + #[serde(default)] + pub observed_generation: i64, +} diff --git a/src/crds/metadata.rs b/src/crds/metadata.rs new file mode 100644 index 0000000..6dfad54 --- /dev/null +++ b/src/crds/metadata.rs @@ -0,0 +1,108 @@ +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::SonarrInstanceRef; + +/// SonarrMetadata represents a metadata consumer configuration in Sonarr +/// Metadata consumers write metadata files for media managers (Kodi, Plex, etc.) +#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[kube( + group = "devopsarr.io", + version = "v1alpha1", + kind = "SonarrMetadata", + plural = "sonarrmetadatas", + shortname = "smeta", + namespaced, + status = "SonarrMetadataStatus", + printcolumn = r#"{"name":"Name","type":"string","jsonPath":".spec.name"}"#, + printcolumn = r#"{"name":"Type","type":"string","jsonPath":".spec.metadataType"}"#, + printcolumn = r#"{"name":"Enabled","type":"boolean","jsonPath":".spec.enable"}"#, + printcolumn = r#"{"name":"ID","type":"integer","jsonPath":".status.id"}"#, + printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type==\"Ready\")].status"}"#, + printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"# +)] +#[serde(rename_all = "camelCase")] +pub struct SonarrMetadataSpec { + /// Reference to the SonarrInstance + pub sonarr_instance_ref: SonarrInstanceRef, + + /// Metadata consumer name + pub name: String, + + /// Metadata type/implementation + pub metadata_type: MetadataType, + + /// Enable this metadata consumer + #[serde(default = "default_true")] + pub enable: bool, + + /// Tags for this metadata consumer + #[serde(default)] + pub tags: Vec, + + /// Metadata-specific configuration + #[serde(default)] + pub config: MetadataConfig, +} + +fn default_true() -> bool { + true +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub enum MetadataType { + /// Kodi (XBMC) / Emby metadata + #[default] + XbmcMetadata, + /// Roksbox metadata + RoksboxMetadata, + /// WDTV metadata + WdtvMetadata, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct MetadataConfig { + /// Write series metadata (series.nfo) + #[serde(default = "default_true")] + pub series_metadata: bool, + + /// Write series metadata URL (deprecated) + #[serde(default)] + pub series_metadata_url: bool, + + /// Write episode metadata (episode.nfo) + #[serde(default = "default_true")] + pub episode_metadata: bool, + + /// Write series images (poster, banner, fanart) + #[serde(default = "default_true")] + pub series_images: bool, + + /// Write season images + #[serde(default = "default_true")] + pub season_images: bool, + + /// Write episode images (thumbnails) + #[serde(default)] + pub episode_images: bool, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SonarrMetadataStatus { + /// Current conditions + #[serde(default)] + pub conditions: Vec, + + /// Sonarr Metadata ID + #[serde(default)] + pub id: Option, + + /// Observed generation + #[serde(default)] + pub observed_generation: i64, +} diff --git a/src/crds/mod.rs b/src/crds/mod.rs new file mode 100644 index 0000000..ad41823 --- /dev/null +++ b/src/crds/mod.rs @@ -0,0 +1,88 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +pub mod auto_tag; +pub mod custom_format; +pub mod delay_profile; +pub mod download_client; +pub mod download_client_config; +pub mod import_list; +pub mod indexer; +pub mod indexer_config; +pub mod language_profile; +pub mod media_management_config; +pub mod metadata; +pub mod naming_config; +pub mod notification; +pub mod quality_definition; +pub mod quality_profile; +pub mod root_folder; +pub mod series; +pub mod sonarr; +pub mod tag; + +pub use auto_tag::{SonarrAutoTag, SonarrAutoTagSpec, SonarrAutoTagStatus}; +pub use custom_format::{SonarrCustomFormat, SonarrCustomFormatSpec, SonarrCustomFormatStatus}; +pub use delay_profile::{SonarrDelayProfile, SonarrDelayProfileSpec, SonarrDelayProfileStatus}; +pub use download_client::{ + SonarrDownloadClient, SonarrDownloadClientSpec, SonarrDownloadClientStatus, +}; +pub use import_list::{SonarrImportList, SonarrImportListSpec, SonarrImportListStatus}; +pub use indexer::{SonarrIndexer, SonarrIndexerSpec, SonarrIndexerStatus}; +pub use language_profile::{ + SonarrLanguageProfile, SonarrLanguageProfileSpec, SonarrLanguageProfileStatus, +}; +pub use metadata::{SonarrMetadata, SonarrMetadataSpec, SonarrMetadataStatus}; +pub use notification::{SonarrNotification, SonarrNotificationSpec, SonarrNotificationStatus}; +pub use quality_definition::{ + SonarrQualityDefinition, SonarrQualityDefinitionSpec, SonarrQualityDefinitionStatus, +}; +pub use quality_profile::{ + SonarrQualityProfile, SonarrQualityProfileSpec, SonarrQualityProfileStatus, +}; +pub use root_folder::{SonarrRootFolder, SonarrRootFolderSpec, SonarrRootFolderStatus}; +pub use series::{SonarrSeries, SonarrSeriesSpec, SonarrSeriesStatus}; +pub use sonarr::{ServiceConfig, Sonarr, SonarrSpec, SonarrStatus, StorageConfig}; +pub use tag::{SonarrTag, SonarrTagSpec, SonarrTagStatus}; + +// Config CRDs (singleton per Sonarr instance) +pub use download_client_config::{ + SonarrDownloadClientConfig, SonarrDownloadClientConfigSpec, SonarrDownloadClientConfigStatus, +}; +pub use indexer_config::{SonarrIndexerConfig, SonarrIndexerConfigSpec, SonarrIndexerConfigStatus}; +pub use media_management_config::{ + SonarrMediaManagementConfig, SonarrMediaManagementConfigSpec, SonarrMediaManagementConfigStatus, +}; +pub use naming_config::{SonarrNamingConfig, SonarrNamingConfigSpec, SonarrNamingConfigStatus}; + +/// Common SecretKeySelector used across CRDs +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SecretKeySelector { + /// Name of the secret + pub name: String, + + /// Key in the secret + pub key: String, +} + +/// Reference to a Sonarr instance used by sub-resources +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SonarrInstanceRef { + /// Name of the SonarrInstance resource + #[serde(default)] + pub name: String, + + /// Namespace of the SonarrInstance (optional, defaults to same namespace) + #[serde(default)] + pub namespace: Option, +} + +/// Common constants for the operator +pub const FINALIZER: &str = "sonarr.io/finalizer"; + +/// Common labels +pub const LABEL_APP: &str = "app.kubernetes.io/name"; +pub const LABEL_INSTANCE: &str = "app.kubernetes.io/instance"; +pub const LABEL_MANAGED_BY: &str = "app.kubernetes.io/managed-by"; diff --git a/src/crds/naming_config.rs b/src/crds/naming_config.rs new file mode 100644 index 0000000..583212d --- /dev/null +++ b/src/crds/naming_config.rs @@ -0,0 +1,95 @@ +//! SonarrNamingConfig CRD +//! +//! Configures episode naming settings for a Sonarr instance. +//! Only one resource per Sonarr instance is allowed. + +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::SonarrInstanceRef; + +/// SonarrNamingConfig configures episode naming settings for a Sonarr instance. +/// Only one SonarrNamingConfig per Sonarr instance is allowed. +#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema, Default)] +#[kube( + group = "devopsarr.io", + version = "v1alpha1", + kind = "SonarrNamingConfig", + plural = "sonarrnamingconfigs", + shortname = "snc", + namespaced, + status = "SonarrNamingConfigStatus", + printcolumn = r#"{"name":"Instance","type":"string","jsonPath":".spec.sonarrInstanceRef.name"}"#, + printcolumn = r#"{"name":"Rename","type":"boolean","jsonPath":".spec.renameEpisodes"}"#, + printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type==\"Ready\")].status"}"#, + printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"# +)] +#[serde(rename_all = "camelCase")] +pub struct SonarrNamingConfigSpec { + /// Reference to the Sonarr instance + pub sonarr_instance_ref: SonarrInstanceRef, + + /// Enable episode renaming + #[serde(default)] + pub rename_episodes: Option, + + /// Replace illegal characters in filenames + #[serde(default)] + pub replace_illegal_characters: Option, + + /// Colon replacement format (0=Delete, 1=Dash, 2=SpaceDash, 3=SpaceDashSpace, 4=Smart) + #[serde(default)] + pub colon_replacement_format: Option, + + /// Custom colon replacement format string + #[serde(default)] + pub custom_colon_replacement_format: Option, + + /// Multi-episode style (0=Extend, 1=Duplicate, 2=Repeat, 3=Scene, 4=Range, 5=PrefixedRange) + #[serde(default)] + pub multi_episode_style: Option, + + /// Standard episode format + /// Example: "{Series Title} - S{season:00}E{episode:00} - {Episode Title} {Quality Full}" + #[serde(default)] + pub standard_episode_format: Option, + + /// Daily episode format + /// Example: "{Series Title} - {Air-Date} - {Episode Title} {Quality Full}" + #[serde(default)] + pub daily_episode_format: Option, + + /// Anime episode format + /// Example: "{Series Title} - S{season:00}E{episode:00} - {Episode Title} {Quality Full}" + #[serde(default)] + pub anime_episode_format: Option, + + /// Series folder format + /// Example: "{Series Title}" + #[serde(default)] + pub series_folder_format: Option, + + /// Season folder format + /// Example: "Season {season}" + #[serde(default)] + pub season_folder_format: Option, + + /// Specials folder format + /// Example: "Specials" + #[serde(default)] + pub specials_folder_format: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SonarrNamingConfigStatus { + /// Current conditions + #[serde(default)] + pub conditions: Vec, + + /// Observed generation + #[serde(default)] + pub observed_generation: i64, +} diff --git a/src/crds/notification.rs b/src/crds/notification.rs new file mode 100644 index 0000000..2351a52 --- /dev/null +++ b/src/crds/notification.rs @@ -0,0 +1,321 @@ +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::SecretKeySelector; +use super::SonarrInstanceRef; + +/// SonarrNotification represents a notification/connect configuration in Sonarr +/// Notifications are used to alert on events (Discord, Telegram, Webhook, etc.) +#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[kube( + group = "devopsarr.io", + version = "v1alpha1", + kind = "SonarrNotification", + plural = "sonarrnotifications", + shortname = "snot", + namespaced, + status = "SonarrNotificationStatus", + printcolumn = r#"{"name":"Name","type":"string","jsonPath":".spec.name"}"#, + printcolumn = r#"{"name":"Type","type":"string","jsonPath":".spec.notificationType"}"#, + printcolumn = r#"{"name":"ID","type":"integer","jsonPath":".status.id"}"#, + printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type==\"Ready\")].status"}"#, + printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"# +)] +#[serde(rename_all = "camelCase")] +pub struct SonarrNotificationSpec { + /// Reference to the SonarrInstance + pub sonarr_instance_ref: SonarrInstanceRef, + + /// Notification name + pub name: String, + + /// Notification type + pub notification_type: NotificationType, + + /// Tags for this notification + #[serde(default)] + pub tags: Vec, + + /// Event triggers + #[serde(default)] + pub triggers: NotificationTriggers, + + /// Notification configuration + pub config: NotificationConfig, +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "PascalCase")] +pub enum NotificationType { + Apprise, + CustomScript, + Discord, + Email, + Emby, + Gotify, + Join, + Kodi, + Mailgun, + Ntfy, + Plex, + Prowl, + Pushbullet, + Pushover, + SendGrid, + Signal, + Simplepush, + Slack, + SynologyIndexer, + Telegram, + Trakt, + Twitter, + Webhook, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct NotificationTriggers { + /// On grab (episode is grabbed) + #[serde(default)] + pub on_grab: bool, + + /// On download (episode is downloaded) + #[serde(default)] + pub on_download: bool, + + /// On upgrade (episode is upgraded) + #[serde(default)] + pub on_upgrade: bool, + + /// On rename + #[serde(default)] + pub on_rename: bool, + + /// On series add + #[serde(default)] + pub on_series_add: bool, + + /// On series delete + #[serde(default)] + pub on_series_delete: bool, + + /// On episode file delete + #[serde(default)] + pub on_episode_file_delete: bool, + + /// On episode file delete for upgrade + #[serde(default)] + pub on_episode_file_delete_for_upgrade: bool, + + /// On health issue + #[serde(default)] + pub on_health_issue: bool, + + /// On health restored + #[serde(default)] + pub on_health_restored: bool, + + /// On application update + #[serde(default)] + pub on_application_update: bool, + + /// On manual interaction required + #[serde(default)] + pub on_manual_interaction_required: bool, + + /// On import complete + #[serde(default)] + pub on_import_complete: bool, + + /// Include health warnings + #[serde(default)] + pub include_health_warnings: bool, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct NotificationConfig { + // Webhook configuration + /// Webhook URL + #[serde(default)] + pub url: Option, + + /// HTTP Method (1 = POST, 2 = PUT) + #[serde(default)] + pub method: Option, + + /// Username for basic auth + #[serde(default)] + pub username: Option, + + /// Password secret reference + #[serde(default)] + pub password_secret_ref: Option, + + // Discord configuration + /// Discord webhook URL + #[serde(default)] + pub webhook_url: Option, + + /// Discord avatar + #[serde(default)] + pub avatar: Option, + + /// Discord username + #[serde(default)] + pub discord_username: Option, + + // Telegram configuration + /// Telegram bot token secret reference + #[serde(default)] + pub bot_token_secret_ref: Option, + + /// Telegram chat ID + #[serde(default)] + pub chat_id: Option, + + /// Send silently + #[serde(default)] + pub send_silently: bool, + + // Email configuration + /// SMTP server + #[serde(default)] + pub server: Option, + + /// SMTP port + #[serde(default)] + pub port: Option, + + /// Use SSL + #[serde(default)] + pub use_ssl: bool, + + /// Require encryption + #[serde(default)] + pub require_encryption: bool, + + /// From address + #[serde(default)] + pub from: Option, + + /// To addresses + #[serde(default)] + pub to: Vec, + + /// CC addresses + #[serde(default)] + pub cc: Vec, + + /// BCC addresses + #[serde(default)] + pub bcc: Vec, + + // Slack configuration + /// Slack webhook URL + #[serde(default)] + pub slack_webhook_url: Option, + + /// Slack channel + #[serde(default)] + pub channel: Option, + + /// Slack icon + #[serde(default)] + pub icon: Option, + + // Plex/Emby configuration + /// Server host + #[serde(default)] + pub host: Option, + + /// Auth token secret reference + #[serde(default)] + pub auth_token_secret_ref: Option, + + /// Update library + #[serde(default)] + pub update_library: bool, + + /// Notify on specific library sections + #[serde(default)] + pub map_to: Option, + + // Gotify configuration + /// Gotify app token secret reference + #[serde(default)] + pub app_token_secret_ref: Option, + + /// Priority level + #[serde(default)] + pub priority: Option, + + // Pushover configuration + /// User key secret reference + #[serde(default)] + pub user_key_secret_ref: Option, + + /// API key secret reference + #[serde(default)] + pub api_key_secret_ref: Option, + + /// Device list + #[serde(default)] + pub devices: Vec, + + /// Sound + #[serde(default)] + pub sound: Option, + + /// Retry interval (seconds) + #[serde(default)] + pub retry: Option, + + /// Expire after (seconds) + #[serde(default)] + pub expire: Option, + + // Custom Script configuration + /// Path to script + #[serde(default)] + pub path: Option, + + /// Script arguments + #[serde(default)] + pub arguments: Option, + + // Ntfy configuration + /// Ntfy server URL + #[serde(default)] + pub server_url: Option, + + /// Ntfy topic + #[serde(default)] + pub topic: Option, + + /// Click URL + #[serde(default)] + pub click_url: Option, + + /// Ntfy tags + #[serde(default)] + pub ntfy_tags: Vec, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SonarrNotificationStatus { + /// Current conditions + #[serde(default)] + pub conditions: Vec, + + /// Sonarr Notification ID + #[serde(default)] + pub id: Option, + + /// Observed generation + #[serde(default)] + pub observed_generation: i64, +} diff --git a/src/crds/quality_definition.rs b/src/crds/quality_definition.rs new file mode 100644 index 0000000..b4a8451 --- /dev/null +++ b/src/crds/quality_definition.rs @@ -0,0 +1,141 @@ +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::SonarrInstanceRef; + +/// SonarrQualityDefinition represents a quality definition configuration in Sonarr +/// Quality definitions control the size limits for each quality level +#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[kube( + group = "devopsarr.io", + version = "v1alpha1", + kind = "SonarrQualityDefinition", + plural = "sonarrqualitydefinitions", + shortname = "sqd", + namespaced, + status = "SonarrQualityDefinitionStatus", + printcolumn = r#"{"name":"Quality","type":"string","jsonPath":".spec.qualityName"}"#, + printcolumn = r#"{"name":"Title","type":"string","jsonPath":".spec.title"}"#, + printcolumn = r#"{"name":"MinSize","type":"number","jsonPath":".spec.minSize"}"#, + printcolumn = r#"{"name":"MaxSize","type":"number","jsonPath":".spec.maxSize"}"#, + printcolumn = r#"{"name":"ID","type":"integer","jsonPath":".status.id"}"#, + printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type==\"Ready\")].status"}"#, + printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"# +)] +#[serde(rename_all = "camelCase")] +pub struct SonarrQualityDefinitionSpec { + /// Reference to the SonarrInstance + pub sonarr_instance_ref: SonarrInstanceRef, + + /// Quality name (must match existing quality in Sonarr) + pub quality_name: QualityName, + + /// Title/display name for this quality + #[serde(default)] + pub title: Option, + + /// Minimum size in MB per minute of runtime + #[serde(default)] + pub min_size: Option, + + /// Maximum size in MB per minute of runtime (None = unlimited) + #[serde(default)] + pub max_size: Option, + + /// Preferred size in MB per minute of runtime + #[serde(default)] + pub preferred_size: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum QualityName { + Unknown, + #[default] + #[serde(rename = "SDTV")] + Sdtv, + #[serde(rename = "DVD")] + Dvd, + #[serde(rename = "WEBDL-480p")] + Webdl480p, + #[serde(rename = "WEBRip-480p")] + Webrip480p, + #[serde(rename = "Bluray-480p")] + Bluray480p, + #[serde(rename = "HDTV-720p")] + Hdtv720p, + #[serde(rename = "HDTV-1080p")] + Hdtv1080p, + #[serde(rename = "Raw-HD")] + RawHd, + #[serde(rename = "WEBDL-720p")] + Webdl720p, + #[serde(rename = "WEBRip-720p")] + Webrip720p, + #[serde(rename = "Bluray-720p")] + Bluray720p, + #[serde(rename = "WEBDL-1080p")] + Webdl1080p, + #[serde(rename = "WEBRip-1080p")] + Webrip1080p, + #[serde(rename = "Bluray-1080p")] + Bluray1080p, + #[serde(rename = "Bluray-1080p Remux")] + Bluray1080pRemux, + #[serde(rename = "HDTV-2160p")] + Hdtv2160p, + #[serde(rename = "WEBDL-2160p")] + Webdl2160p, + #[serde(rename = "WEBRip-2160p")] + Webrip2160p, + #[serde(rename = "Bluray-2160p")] + Bluray2160p, + #[serde(rename = "Bluray-2160p Remux")] + Bluray2160pRemux, +} + +impl QualityName { + pub fn to_quality_id(&self) -> i32 { + match self { + QualityName::Unknown => 0, + QualityName::Sdtv => 1, + QualityName::Dvd => 2, + QualityName::Webdl480p => 8, + QualityName::Webrip480p => 12, + QualityName::Bluray480p => 20, + QualityName::Hdtv720p => 4, + QualityName::Hdtv1080p => 9, + QualityName::RawHd => 10, + QualityName::Webdl720p => 5, + QualityName::Webrip720p => 14, + QualityName::Bluray720p => 6, + QualityName::Webdl1080p => 3, + QualityName::Webrip1080p => 15, + QualityName::Bluray1080p => 7, + QualityName::Bluray1080pRemux => 30, + QualityName::Hdtv2160p => 16, + QualityName::Webdl2160p => 18, + QualityName::Webrip2160p => 17, + QualityName::Bluray2160p => 19, + QualityName::Bluray2160pRemux => 31, + } + } +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SonarrQualityDefinitionStatus { + /// Current conditions + #[serde(default)] + pub conditions: Vec, + + /// Sonarr Quality Definition ID + #[serde(default)] + pub id: Option, + + /// Observed generation + #[serde(default)] + pub observed_generation: i64, +} diff --git a/src/crds/quality_profile.rs b/src/crds/quality_profile.rs new file mode 100644 index 0000000..571b5ff --- /dev/null +++ b/src/crds/quality_profile.rs @@ -0,0 +1,126 @@ +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::SonarrInstanceRef; + +/// SonarrQualityProfile represents a quality profile in Sonarr +/// Quality profiles define which qualities are acceptable and their priority +#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[kube( + group = "devopsarr.io", + version = "v1alpha1", + kind = "SonarrQualityProfile", + plural = "sonarrqualityprofiles", + shortname = "sqp", + namespaced, + status = "SonarrQualityProfileStatus", + printcolumn = r#"{"name":"Name","type":"string","jsonPath":".spec.name"}"#, + printcolumn = r#"{"name":"Cutoff","type":"integer","jsonPath":".spec.cutoff"}"#, + printcolumn = r#"{"name":"ID","type":"integer","jsonPath":".status.id"}"#, + printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type==\"Ready\")].status"}"#, + printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"# +)] +#[serde(rename_all = "camelCase")] +pub struct SonarrQualityProfileSpec { + /// Reference to the SonarrInstance + pub sonarr_instance_ref: SonarrInstanceRef, + + /// Quality profile name + pub name: String, + + /// Whether upgrades are allowed + #[serde(default)] + pub upgrade_allowed: bool, + + /// Quality ID to use as cutoff + #[serde(default)] + pub cutoff: i32, + + /// Cutoff format score + #[serde(default)] + pub cutoff_format_score: Option, + + /// Minimum format score + #[serde(default)] + pub min_format_score: Option, + + /// Minimum upgrade format score + #[serde(default)] + pub min_upgrade_format_score: Option, + + /// Ordered list of quality groups + pub quality_groups: Vec, + + /// Format items (custom formats with scores) + #[serde(default)] + pub format_items: Vec, +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct QualityGroup { + /// Quality group ID + #[serde(default)] + pub id: Option, + + /// Quality group name + #[serde(default)] + pub name: Option, + + /// Ordered list of qualities in this group + pub qualities: Vec, +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct Quality { + /// Quality ID + #[serde(default)] + pub id: Option, + + /// Quality name + #[serde(default)] + pub name: Option, + + /// Source type + #[serde(default)] + pub source: Option, + + /// Resolution + #[serde(default)] + pub resolution: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct FormatItem { + /// Custom format ID + #[serde(default)] + pub format: Option, + + /// Format name + #[serde(default)] + pub name: Option, + + /// Score for this format + #[serde(default)] + pub score: i32, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SonarrQualityProfileStatus { + /// Current conditions + #[serde(default)] + pub conditions: Vec, + + /// Sonarr Quality Profile ID + #[serde(default)] + pub id: Option, + + /// Observed generation + #[serde(default)] + pub observed_generation: i64, +} diff --git a/src/crds/root_folder.rs b/src/crds/root_folder.rs new file mode 100644 index 0000000..628a949 --- /dev/null +++ b/src/crds/root_folder.rs @@ -0,0 +1,55 @@ +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::SonarrInstanceRef; + +/// SonarrRootFolder represents a root folder in Sonarr +/// Root folders are the base directories where series are stored +#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[kube( + group = "devopsarr.io", + version = "v1alpha1", + kind = "SonarrRootFolder", + plural = "sonarrrootfolders", + shortname = "srf", + namespaced, + status = "SonarrRootFolderStatus", + printcolumn = r#"{"name":"Path","type":"string","jsonPath":".spec.path"}"#, + printcolumn = r#"{"name":"ID","type":"integer","jsonPath":".status.id"}"#, + printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type==\"Ready\")].status"}"#, + printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"# +)] +#[serde(rename_all = "camelCase")] +pub struct SonarrRootFolderSpec { + /// Reference to the SonarrInstance + pub sonarr_instance_ref: SonarrInstanceRef, + + /// Root folder absolute path + pub path: String, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SonarrRootFolderStatus { + /// Current conditions + #[serde(default)] + pub conditions: Vec, + + /// Sonarr Root Folder ID + #[serde(default)] + pub id: Option, + + /// Whether the folder is accessible + #[serde(default)] + pub accessible: Option, + + /// Free space in the folder + #[serde(default)] + pub free_space: Option, + + /// Observed generation + #[serde(default)] + pub observed_generation: i64, +} diff --git a/src/crds/series.rs b/src/crds/series.rs new file mode 100644 index 0000000..f423b83 --- /dev/null +++ b/src/crds/series.rs @@ -0,0 +1,200 @@ +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::SonarrInstanceRef; + +/// SonarrSeries represents a TV series managed in Sonarr +/// This allows declarative management of series in your library +#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[kube( + group = "devopsarr.io", + version = "v1alpha1", + kind = "SonarrSeries", + plural = "sonarrseries", + shortname = "ss", + namespaced, + status = "SonarrSeriesStatus", + printcolumn = r#"{"name":"Title","type":"string","jsonPath":".spec.title"}"#, + printcolumn = r#"{"name":"TVDB ID","type":"integer","jsonPath":".spec.tvdbId"}"#, + printcolumn = r#"{"name":"Monitored","type":"boolean","jsonPath":".spec.monitored"}"#, + printcolumn = r#"{"name":"ID","type":"integer","jsonPath":".status.id"}"#, + printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type==\"Ready\")].status"}"#, + printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"# +)] +#[serde(rename_all = "camelCase")] +pub struct SonarrSeriesSpec { + /// Reference to the SonarrInstance + pub sonarr_instance_ref: SonarrInstanceRef, + + /// Series title + pub title: String, + + /// TVDB ID for the series + pub tvdb_id: i32, + + /// Title slug (kebab-case version of title) + pub title_slug: String, + + /// Quality profile ID or name reference + pub quality_profile: QualityProfileRef, + + /// Root folder path for the series + pub root_folder_path: String, + + /// Whether the series is monitored + #[serde(default = "default_true")] + pub monitored: bool, + + /// Use season folders + #[serde(default = "default_true")] + pub season_folder: bool, + + /// Use scene numbering + #[serde(default)] + pub use_scene_numbering: bool, + + /// Series type + #[serde(default = "default_series_type")] + pub series_type: SeriesType, + + /// Tags for this series + #[serde(default)] + pub tags: Vec, + + /// Specific path override (optional) + #[serde(default)] + pub path: Option, + + /// Monitor type for adding series + #[serde(default = "default_monitor_type")] + pub add_options: AddSeriesOptions, +} + +fn default_true() -> bool { + true +} + +fn default_series_type() -> SeriesType { + SeriesType::Standard +} + +fn default_monitor_type() -> AddSeriesOptions { + AddSeriesOptions { + monitor: MonitorType::All, + search_for_missing_episodes: true, + search_for_cutoff_unmet_episodes: false, + } +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct QualityProfileRef { + /// Quality profile ID + #[serde(default)] + pub id: Option, + + /// Quality profile name (will be resolved to ID) + #[serde(default)] + pub name: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum SeriesType { + Standard, + Daily, + Anime, +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct AddSeriesOptions { + /// Monitor type + #[serde(default = "default_monitor_all")] + pub monitor: MonitorType, + + /// Search for missing episodes when adding + #[serde(default = "default_true_fn")] + pub search_for_missing_episodes: bool, + + /// Search for cutoff unmet episodes + #[serde(default)] + pub search_for_cutoff_unmet_episodes: bool, +} + +fn default_monitor_all() -> MonitorType { + MonitorType::All +} + +fn default_true_fn() -> bool { + true +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum MonitorType { + /// Monitor all episodes + All, + /// Monitor future episodes only + Future, + /// Monitor missing episodes only + Missing, + /// Monitor existing episodes only + Existing, + /// Monitor recent episodes only + Recent, + /// Monitor pilot episode only + Pilot, + /// Monitor first season only + FirstSeason, + /// Monitor last season only + LastSeason, + /// Monitor no episodes + None, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SonarrSeriesStatus { + /// Current conditions + #[serde(default)] + pub conditions: Vec, + + /// Sonarr Series ID + #[serde(default)] + pub id: Option, + + /// Observed generation + #[serde(default)] + pub observed_generation: i64, + + /// Total episode count + #[serde(default)] + pub episode_count: Option, + + /// Episode file count + #[serde(default)] + pub episode_file_count: Option, + + /// Percentage complete + #[serde(default)] + pub percent_complete: Option, + + /// Next airing date + #[serde(default)] + pub next_airing: Option, + + /// Previous airing date + #[serde(default)] + pub previous_airing: Option, + + /// Network + #[serde(default)] + pub network: Option, + + /// Status (continuing, ended, etc.) + #[serde(default)] + pub series_status: Option, +} diff --git a/src/crds/sonarr.rs b/src/crds/sonarr.rs new file mode 100644 index 0000000..2e2cda1 --- /dev/null +++ b/src/crds/sonarr.rs @@ -0,0 +1,636 @@ +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::SecretKeySelector; + +/// Sonarr is the main CRD that deploys and manages a Sonarr instance +/// +/// This CRD creates: +/// - A Deployment with the Sonarr container +/// - An init container for database migrations +/// - A Service to expose Sonarr +/// - A PersistentVolumeClaim for configuration storage +/// - Optional Ingress for external access +#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[kube( + group = "devopsarr.io", + version = "v1alpha1", + kind = "Sonarr", + plural = "sonarrs", + shortname = "snr", + namespaced, + status = "SonarrStatus", + printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type==\"Ready\")].status"}"#, + printcolumn = r#"{"name":"URL","type":"string","jsonPath":".status.url"}"#, + printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"# +)] +#[serde(rename_all = "camelCase")] +pub struct SonarrSpec { + /// Sonarr image to use (default: lscr.io/linuxserver/sonarr:latest) + #[serde(default = "default_image")] + pub image: String, + + /// Image pull policy (default: IfNotPresent) + #[serde(default = "default_image_pull_policy")] + pub image_pull_policy: String, + + /// Number of replicas (should be 1 for Sonarr) + #[serde(default = "default_replicas")] + pub replicas: i32, + + /// Storage configuration + #[serde(default)] + pub storage: StorageConfig, + + /// Service configuration + #[serde(default)] + pub service: ServiceConfig, + + /// Ingress configuration (optional) + #[serde(default)] + pub ingress: Option, + + /// HTTPRoute configuration for Gateway API (optional) + #[serde(default)] + pub http_route: Option, + + /// Environment variables + #[serde(default)] + pub env: Vec, + + /// Resource requirements + #[serde(default)] + pub resources: Option, + + /// Volume mounts for media directories + #[serde(default)] + pub volume_mounts: Vec, + + /// Additional volumes + #[serde(default)] + pub volumes: Vec, + + /// Node selector + #[serde(default)] + pub node_selector: std::collections::BTreeMap, + + /// Tolerations + #[serde(default)] + pub tolerations: Vec, + + /// Pod security context + #[serde(default)] + pub security_context: Option, + + /// Init container configuration (for custom init logic) + #[serde(default)] + pub init_container: Option, + + /// API key secret reference (optional - will be auto-generated if not provided) + #[serde(default)] + pub api_key_secret_ref: Option, + + /// Sonarr application configuration (config.xml settings) + #[serde(default)] + pub config: SonarrConfig, +} + +/// Configuration for Sonarr's config.xml +/// These settings are applied by the init container on startup +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SonarrConfig { + /// Init container image used to configure config.xml (default: busybox:latest) + #[serde(default)] + pub init_container_image: Option, + + /// URL base for reverse proxy setups (e.g., "/sonarr") + #[serde(default)] + pub url_base: Option, + + /// Bind address (default: "*") + #[serde(default)] + pub bind_address: Option, + + /// Log level: trace, debug, info, warn, error (default: info) + #[serde(default)] + pub log_level: Option, + + /// Instance name displayed in the UI + #[serde(default)] + pub instance_name: Option, + + /// Authentication method: None, Basic, Forms, External (default: None) + #[serde(default)] + pub authentication_method: Option, + + /// Authentication required for API access (default: false) + #[serde(default)] + pub authentication_required: Option, + + /// Analytics enabled (default: true) + #[serde(default)] + pub analytics_enabled: Option, +} + +impl Default for SonarrSpec { + fn default() -> Self { + Self { + image: default_image(), + image_pull_policy: default_image_pull_policy(), + replicas: default_replicas(), + storage: StorageConfig::default(), + service: ServiceConfig::default(), + ingress: None, + http_route: None, + env: Vec::new(), + resources: None, + volume_mounts: Vec::new(), + volumes: Vec::new(), + node_selector: std::collections::BTreeMap::new(), + tolerations: Vec::new(), + security_context: None, + init_container: None, + api_key_secret_ref: None, + config: SonarrConfig::default(), + } + } +} + +fn default_image() -> String { + "lscr.io/linuxserver/sonarr:latest".to_string() +} + +fn default_image_pull_policy() -> String { + "IfNotPresent".to_string() +} + +fn default_replicas() -> i32 { + 1 +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct StorageConfig { + /// Storage class for the PVC + #[serde(default)] + pub storage_class: Option, + + /// Size of the config PVC (default: 1Gi) + #[serde(default = "default_storage_size")] + pub size: String, + + /// Access modes (default: ReadWriteOnce) + #[serde(default = "default_access_modes")] + pub access_modes: Vec, + + /// Existing PVC to use (optional) + #[serde(default)] + pub existing_claim: Option, +} + +fn default_storage_size() -> String { + "1Gi".to_string() +} + +fn default_access_modes() -> Vec { + vec!["ReadWriteOnce".to_string()] +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ServiceConfig { + /// Service type (default: ClusterIP) + #[serde(default = "default_service_type")] + pub service_type: String, + + /// Service port (default: 8989) + #[serde(default = "default_service_port")] + pub port: i32, + + /// Container port - the port Sonarr listens on inside the container (default: 8989) + #[serde(default = "default_container_port")] + pub container_port: i32, + + /// Node port (only for NodePort type) + #[serde(default)] + pub node_port: Option, + + /// Service annotations + #[serde(default)] + pub annotations: std::collections::BTreeMap, +} + +fn default_service_type() -> String { + "ClusterIP".to_string() +} + +fn default_service_port() -> i32 { + 8989 +} + +fn default_container_port() -> i32 { + 8989 +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct IngressConfig { + /// Enable ingress (default: false) + #[serde(default)] + pub enabled: bool, + + /// Ingress class name + #[serde(default)] + pub ingress_class_name: Option, + + /// Hostname for the ingress + pub host: String, + + /// Path for the ingress (default: /) + #[serde(default = "default_ingress_path")] + pub path: String, + + /// Path type (default: Prefix) + #[serde(default = "default_path_type")] + pub path_type: String, + + /// TLS configuration + #[serde(default)] + pub tls: Option, + + /// Ingress annotations + #[serde(default)] + pub annotations: std::collections::BTreeMap, +} + +fn default_ingress_path() -> String { + "/".to_string() +} + +fn default_path_type() -> String { + "Prefix".to_string() +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct IngressTLS { + /// Secret name containing TLS certificate + pub secret_name: String, + + /// Hosts covered by the TLS certificate + #[serde(default)] + pub hosts: Vec, +} + +/// HTTPRoute configuration for Gateway API +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct HTTPRouteConfig { + /// Enable HTTPRoute creation (default: false) + #[serde(default)] + pub enabled: bool, + + /// Gateway reference - the Gateway to attach to + pub gateway_ref: GatewayRef, + + /// Hostnames for the HTTPRoute + #[serde(default)] + pub hostnames: Vec, + + /// Path match for the route (default: /) + #[serde(default = "default_http_route_path")] + pub path: String, + + /// Path match type: Exact, PathPrefix, or RegularExpression (default: PathPrefix) + #[serde(default = "default_http_route_path_type")] + pub path_type: String, + + /// Additional labels for the HTTPRoute + #[serde(default)] + pub labels: std::collections::BTreeMap, + + /// Additional annotations for the HTTPRoute + #[serde(default)] + pub annotations: std::collections::BTreeMap, +} + +/// Gateway reference for HTTPRoute +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct GatewayRef { + /// Name of the Gateway + pub name: String, + + /// Namespace of the Gateway (optional, defaults to same namespace as HTTPRoute) + #[serde(default)] + pub namespace: Option, + + /// Section name within the Gateway (optional) + #[serde(default)] + pub section_name: Option, +} + +fn default_http_route_path() -> String { + "/".to_string() +} + +fn default_http_route_path_type() -> String { + "PathPrefix".to_string() +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct EnvVar { + /// Name of the environment variable + pub name: String, + + /// Value of the environment variable + #[serde(default)] + pub value: Option, + + /// Reference to a secret or configmap + #[serde(default)] + pub value_from: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct EnvVarSource { + /// Secret key reference + #[serde(default)] + pub secret_key_ref: Option, + + /// ConfigMap key reference + #[serde(default)] + pub config_map_key_ref: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ConfigMapKeySelector { + /// Name of the configmap + pub name: String, + + /// Key in the configmap + pub key: String, +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ResourceRequirements { + /// Resource limits + #[serde(default)] + pub limits: std::collections::BTreeMap, + + /// Resource requests + #[serde(default)] + pub requests: std::collections::BTreeMap, +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct VolumeMount { + /// Name of the volume + pub name: String, + + /// Mount path inside the container + pub mount_path: String, + + /// Sub path (optional) + #[serde(default)] + pub sub_path: Option, + + /// Read only flag + #[serde(default)] + pub read_only: bool, +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct Volume { + /// Name of the volume + pub name: String, + + /// PVC claim + #[serde(default)] + pub persistent_volume_claim: Option, + + /// HostPath volume + #[serde(default)] + pub host_path: Option, + + /// NFS volume + #[serde(default)] + pub nfs: Option, + + /// ConfigMap volume + #[serde(default)] + pub config_map: Option, + + /// Empty dir volume + #[serde(default)] + pub empty_dir: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct PersistentVolumeClaimVolumeSource { + pub claim_name: String, + #[serde(default)] + pub read_only: bool, +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct HostPathVolumeSource { + pub path: String, + #[serde(rename = "type", default)] + pub host_path_type: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct NFSVolumeSource { + pub server: String, + pub path: String, + #[serde(default)] + pub read_only: bool, +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ConfigMapVolumeSource { + pub name: String, + #[serde(default)] + pub items: Vec, +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct KeyToPath { + pub key: String, + pub path: String, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct EmptyDirVolumeSource { + #[serde(default)] + pub medium: Option, + #[serde(default)] + pub size_limit: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct Toleration { + #[serde(default)] + pub key: Option, + #[serde(default)] + pub operator: Option, + #[serde(default)] + pub value: Option, + #[serde(default)] + pub effect: Option, + #[serde(default)] + pub toleration_seconds: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct PodSecurityContext { + #[serde(default)] + pub run_as_user: Option, + #[serde(default)] + pub run_as_group: Option, + #[serde(default)] + pub fs_group: Option, + #[serde(default)] + pub run_as_non_root: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct InitContainerConfig { + /// Image for init container (default: busybox:latest) + #[serde(default = "default_init_image")] + pub image: String, + + /// Command to run in init container + #[serde(default)] + pub command: Vec, + + /// Arguments for the command + #[serde(default)] + pub args: Vec, + + /// Environment variables for init container + #[serde(default)] + pub env: Vec, +} + +fn default_init_image() -> String { + "busybox:latest".to_string() +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SonarrStatus { + /// Current conditions + #[serde(default)] + pub conditions: Vec, + + /// URL to access Sonarr + #[serde(default)] + pub url: Option, + + /// API key (stored in secret) + #[serde(default)] + pub api_key_secret: Option, + + /// Observed generation + #[serde(default)] + pub observed_generation: i64, + + /// Number of ready replicas + #[serde(default)] + pub ready_replicas: i32, + + /// Sonarr version + #[serde(default)] + pub version: Option, +} + +impl Sonarr { + /// Get the service port (default: 8989) + pub fn service_port(&self) -> i32 { + if self.spec.service.port == 0 { + 8989 + } else { + self.spec.service.port + } + } + + /// Get the container port (default: 8989) + pub fn container_port(&self) -> i32 { + if self.spec.service.container_port == 0 { + 8989 + } else { + self.spec.service.container_port + } + } + + /// Get the service type (default: ClusterIP) + pub fn service_type(&self) -> String { + if self.spec.service.service_type.is_empty() { + "ClusterIP".to_string() + } else { + self.spec.service.service_type.clone() + } + } + + /// Get the service name for this instance + pub fn service_name(&self) -> String { + format!( + "{}-sonarr", + self.metadata.name.as_deref().unwrap_or("unknown") + ) + } + + /// Get the deployment name for this instance + pub fn deployment_name(&self) -> String { + format!( + "{}-sonarr", + self.metadata.name.as_deref().unwrap_or("unknown") + ) + } + + /// Get the PVC name for this instance + pub fn pvc_name(&self) -> String { + format!( + "{}-sonarr-config", + self.metadata.name.as_deref().unwrap_or("unknown") + ) + } + + /// Get the secret name for API key + pub fn api_key_secret_name(&self) -> String { + format!( + "{}-sonarr-apikey", + self.metadata.name.as_deref().unwrap_or("unknown") + ) + } + + /// Get the internal URL for Sonarr + pub fn internal_url(&self, namespace: &str) -> String { + format!( + "http://{}.{}.svc.cluster.local:{}", + self.service_name(), + namespace, + self.service_port() + ) + } +} diff --git a/src/crds/tag.rs b/src/crds/tag.rs new file mode 100644 index 0000000..f4dd5d5 --- /dev/null +++ b/src/crds/tag.rs @@ -0,0 +1,47 @@ +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::SonarrInstanceRef; + +/// SonarrTag represents a tag in Sonarr +/// Tags are used to organize and filter series, profiles, and other resources +#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[kube( + group = "devopsarr.io", + version = "v1alpha1", + kind = "SonarrTag", + plural = "sonarrtags", + shortname = "stag", + namespaced, + status = "SonarrTagStatus", + printcolumn = r#"{"name":"Label","type":"string","jsonPath":".spec.label"}"#, + printcolumn = r#"{"name":"ID","type":"integer","jsonPath":".status.id"}"#, + printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type==\"Ready\")].status"}"#, + printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"# +)] +#[serde(rename_all = "camelCase")] +pub struct SonarrTagSpec { + /// Reference to the SonarrInstance + pub sonarr_instance_ref: SonarrInstanceRef, + + /// Tag label (must be lowercase) + pub label: String, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SonarrTagStatus { + /// Current conditions + #[serde(default)] + pub conditions: Vec, + + /// Sonarr Tag ID + #[serde(default)] + pub id: Option, + + /// Observed generation + #[serde(default)] + pub observed_generation: i64, +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..2db10d6 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,52 @@ +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum Error { + #[error("Kubernetes API error: {0}")] + KubeError(#[from] kube::Error), + + #[error("Sonarr API error: {0}")] + SonarrApiError(String), + + #[error("Serialization error: {0}")] + SerializationError(#[from] serde_json::Error), + + #[error("Missing object key: {0}")] + MissingObjectKey(&'static str), + + #[error("Missing Sonarr reference: {0}")] + MissingSonarrRef(String), + + #[error("Missing Sonarr API credentials")] + MissingApiCredentials, + + #[error("Sonarr instance not found: {0}")] + SonarrInstanceNotFound(String), + + #[error("Sonarr instance not ready: {0}")] + SonarrInstanceNotReady(String), + + #[error("Invalid configuration: {0}")] + InvalidConfiguration(String), + + #[error("Finalizer error: {0}")] + FinalizerError(#[source] Box>), + + #[error("{0}")] + Other(String), +} + +pub type Result = std::result::Result; + +impl Error { + pub fn missing_object_key(key: &'static str) -> Self { + Error::MissingObjectKey(key) + } +} + +/// Convert sonarr API errors to our error type +impl From> for Error { + fn from(err: sonarr::apis::Error) -> Self { + Error::SonarrApiError(format!("{:?}", err)) + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..93ae23f --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,23 @@ +//! Sonarr Kubernetes Operator Library +//! +//! This library provides the core types and functionality for the Sonarr Kubernetes Operator. +//! It exposes CRDs, controllers, and API clients for managing Sonarr instances via Kubernetes. + +pub mod api; +pub mod controllers; +pub mod crds; +pub mod error; + +pub use error::{Error, Result}; + +use crate::api::SonarrClientFactory; +use kube::Client; +use std::sync::Arc; + +/// Shared context for all controllers +pub struct Context { + /// Kubernetes client + pub client: Client, + /// Sonarr API client factory + pub sonarr_client_factory: Arc, +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..c220fb8 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,91 @@ +use kube::Client; +use std::sync::Arc; +use tracing::{error, info}; + +use sonarr_operator::{Context, api, controllers}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // Install the ring crypto provider for rustls + rustls::crypto::ring::default_provider() + .install_default() + .expect("Failed to install rustls crypto provider"); + + // Initialize tracing + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "sonarr_operator=info,kube=info".into()), + ) + .json() + .init(); + + info!("Starting Sonarr Kubernetes Operator"); + + // Create Kubernetes client + let client = Client::try_default().await?; + info!("Connected to Kubernetes cluster"); + + // Create shared context + let context = Arc::new(Context { + client: client.clone(), + sonarr_client_factory: Arc::new(api::SonarrClientFactory::new()), + }); + + // Start all controllers concurrently + let sonarr_controller = controllers::sonarr::run(client.clone(), context.clone()); + let tag_controller = controllers::tag::run(client.clone(), context.clone()); + let root_folder_controller = controllers::root_folder::run(client.clone(), context.clone()); + let quality_profile_controller = + controllers::quality_profile::run(client.clone(), context.clone()); + let indexer_controller = controllers::indexer::run(client.clone(), context.clone()); + let download_client_controller = + controllers::download_client::run(client.clone(), context.clone()); + let notification_controller = controllers::notification::run(client.clone(), context.clone()); + let series_controller = controllers::series::run(client.clone(), context.clone()); + let import_list_controller = controllers::import_list::run(client.clone(), context.clone()); + let language_profile_controller = + controllers::language_profile::run(client.clone(), context.clone()); + let metadata_controller = controllers::metadata::run(client.clone(), context.clone()); + let custom_format_controller = controllers::custom_format::run(client.clone(), context.clone()); + let delay_profile_controller = controllers::delay_profile::run(client.clone(), context.clone()); + let quality_definition_controller = + controllers::quality_definition::run(client.clone(), context.clone()); + let auto_tag_controller = controllers::auto_tag::run(client.clone(), context.clone()); + + // Config controllers (singleton per Sonarr instance) + let media_management_config_controller = + controllers::media_management_config::run(client.clone(), context.clone()); + let naming_config_controller = controllers::naming_config::run(client.clone(), context.clone()); + let indexer_config_controller = + controllers::indexer_config::run(client.clone(), context.clone()); + let download_client_config_controller = + controllers::download_client_config::run(client.clone(), context.clone()); + + info!("All controllers started"); + + // Run all controllers + tokio::select! { + _ = sonarr_controller => error!("Sonarr controller exited"), + _ = tag_controller => error!("Tag controller exited"), + _ = root_folder_controller => error!("Root folder controller exited"), + _ = quality_profile_controller => error!("Quality profile controller exited"), + _ = indexer_controller => error!("Indexer controller exited"), + _ = download_client_controller => error!("Download client controller exited"), + _ = notification_controller => error!("Notification controller exited"), + _ = series_controller => error!("Series controller exited"), + _ = import_list_controller => error!("Import list controller exited"), + _ = language_profile_controller => error!("Language profile controller exited"), + _ = metadata_controller => error!("Metadata controller exited"), + _ = custom_format_controller => error!("Custom format controller exited"), + _ = delay_profile_controller => error!("Delay profile controller exited"), + _ = quality_definition_controller => error!("Quality definition controller exited"), + _ = auto_tag_controller => error!("Auto tag controller exited"), + _ = media_management_config_controller => error!("Media management config controller exited"), + _ = naming_config_controller => error!("Naming config controller exited"), + _ = indexer_config_controller => error!("Indexer config controller exited"), + _ = download_client_config_controller => error!("Download client config controller exited"), + } + + Ok(()) +} diff --git a/tests/e2e/common.rs b/tests/e2e/common.rs new file mode 100644 index 0000000..128da7c --- /dev/null +++ b/tests/e2e/common.rs @@ -0,0 +1,525 @@ +//! Common utilities for E2E tests + +use k8s_openapi::api::core::v1::{Namespace, Secret}; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::{ + Client, Resource, + api::{Api, DeleteParams, Patch, PatchParams}, +}; +use std::collections::BTreeMap; +use std::time::Duration; +use tokio::time::timeout; + +/// E2E test namespace +pub const E2E_NAMESPACE: &str = "sonarr-e2e-test"; + +/// Default timeout for E2E operations (longer than integration tests) +pub const E2E_TIMEOUT: Duration = Duration::from_secs(120); + +/// Short timeout for quick checks +pub const QUICK_TIMEOUT: Duration = Duration::from_secs(30); + +/// Sonarr service details for E2E tests +pub const SONARR_SERVICE_NAME: &str = "sonarr"; +pub const SONARR_SERVICE_PORT: u16 = 8989; + +/// Create a Kubernetes client +pub async fn e2e_client() -> Client { + Client::try_default() + .await + .expect("Failed to create Kubernetes client - is your kubeconfig configured?") +} + +/// Setup the E2E test namespace with Sonarr connection secret +pub async fn setup_e2e_namespace(client: &Client) -> Result<(), anyhow::Error> { + // Create namespace + let namespaces: Api = Api::all(client.clone()); + let ns = Namespace { + metadata: ObjectMeta { + name: Some(E2E_NAMESPACE.to_string()), + labels: Some(BTreeMap::from([ + ( + "app.kubernetes.io/managed-by".to_string(), + "sonarr-e2e".to_string(), + ), + ("test-type".to_string(), "e2e".to_string()), + ])), + ..Default::default() + }, + ..Default::default() + }; + + let patch_params = PatchParams::apply("sonarr-e2e-test").force(); + namespaces + .patch(E2E_NAMESPACE, &patch_params, &Patch::Apply(&ns)) + .await?; + + // Wait for namespace to be active + tokio::time::sleep(Duration::from_secs(1)).await; + + Ok(()) +} + +/// Create the Sonarr connection secret for tests +pub async fn create_sonarr_secret( + client: &Client, + api_key: &str, + url: &str, +) -> Result<(), anyhow::Error> { + let secrets: Api = Api::namespaced(client.clone(), E2E_NAMESPACE); + + let secret = Secret { + metadata: ObjectMeta { + name: Some("sonarr-api-key".to_string()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + string_data: Some(BTreeMap::from([ + ("api-key".to_string(), api_key.to_string()), + ("url".to_string(), url.to_string()), + ])), + ..Default::default() + }; + + let patch_params = PatchParams::apply("sonarr-e2e-test").force(); + secrets + .patch("sonarr-api-key", &patch_params, &Patch::Apply(&secret)) + .await?; + + Ok(()) +} + +/// Cleanup the E2E test namespace +pub async fn cleanup_e2e_namespace(client: &Client) -> Result<(), anyhow::Error> { + let namespaces: Api = Api::all(client.clone()); + + match namespaces + .delete(E2E_NAMESPACE, &DeleteParams::default()) + .await + { + Ok(_) => { + // Wait for namespace deletion + let _ = timeout(Duration::from_secs(60), async { + loop { + match namespaces.get(E2E_NAMESPACE).await { + Err(_) => break, + Ok(_) => tokio::time::sleep(Duration::from_secs(1)).await, + } + } + }) + .await; + Ok(()) + } + Err(kube::Error::Api(err)) if err.code == 404 => Ok(()), + Err(e) => Err(e.into()), + } +} + +/// Apply a namespaced resource +pub async fn apply_resource(client: &Client, resource: &T) -> Result +where + T: Resource + + Clone + + serde::Serialize + + serde::de::DeserializeOwned + + std::fmt::Debug, + ::DynamicType: Default, +{ + let namespace = resource + .meta() + .namespace + .as_deref() + .unwrap_or(E2E_NAMESPACE); + let api: Api = Api::namespaced(client.clone(), namespace); + let name = resource.meta().name.clone().unwrap_or_default(); + + let patch_params = PatchParams::apply("sonarr-e2e-test").force(); + api.patch(&name, &patch_params, &Patch::Apply(resource)) + .await +} + +/// Delete a namespaced resource +pub async fn delete_resource( + client: &Client, + namespace: &str, + name: &str, +) -> Result<(), kube::Error> +where + T: Resource + + Clone + + serde::Serialize + + serde::de::DeserializeOwned + + std::fmt::Debug, + ::DynamicType: Default, +{ + let api: Api = Api::namespaced(client.clone(), namespace); + + match api.delete(name, &DeleteParams::default()).await { + Ok(_) => Ok(()), + Err(kube::Error::Api(err)) if err.code == 404 => Ok(()), + Err(e) => Err(e), + } +} + +/// Wait for a resource to have a Ready condition +pub async fn wait_for_ready( + client: &Client, + namespace: &str, + name: &str, + timeout_duration: Duration, +) -> Result +where + T: Resource + + Clone + + serde::Serialize + + serde::de::DeserializeOwned + + std::fmt::Debug + + HasConditions, + ::DynamicType: Default, +{ + let api: Api = Api::namespaced(client.clone(), namespace); + + timeout(timeout_duration, async { + loop { + match api.get(name).await { + Ok(resource) => { + if resource.is_ready() { + return Ok(resource); + } + // Check for error conditions + if let Some(msg) = resource.get_error_message() { + return Err(anyhow::anyhow!("Resource has error condition: {}", msg)); + } + } + Err(e) => { + tracing::debug!("Waiting for resource {}: {:?}", name, e); + } + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + }) + .await + .map_err(|_| anyhow::anyhow!("Timeout waiting for {} to be ready", name))? +} + +/// Wait for a resource to be deleted +pub async fn wait_for_deletion( + client: &Client, + namespace: &str, + name: &str, + timeout_duration: Duration, +) -> Result<(), anyhow::Error> +where + T: Resource + + Clone + + serde::Serialize + + serde::de::DeserializeOwned + + std::fmt::Debug, + ::DynamicType: Default, +{ + let api: Api = Api::namespaced(client.clone(), namespace); + + timeout(timeout_duration, async { + loop { + match api.get(name).await { + Err(kube::Error::Api(err)) if err.code == 404 => { + return Ok(()); + } + _ => { + tokio::time::sleep(Duration::from_millis(500)).await; + } + } + } + }) + .await + .map_err(|_| anyhow::anyhow!("Timeout waiting for {} to be deleted", name))? +} + +/// Trait for resources that have conditions in their status +pub trait HasConditions { + fn is_ready(&self) -> bool; + fn get_error_message(&self) -> Option; +} + +/// Generate a unique test name +pub fn unique_name(prefix: &str) -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis(); + format!("{}-{}", prefix, timestamp % 100000) +} + +/// Test context that tracks resources for cleanup +pub struct TestContext { + pub client: Client, + pub sonarr: super::SonarrTestClient, + cleanup_resources: Vec, +} + +struct CleanupResource { + kind: String, + namespace: String, + name: String, +} + +impl TestContext { + pub async fn new() -> Result { + let client = e2e_client().await; + + // Get Sonarr URL and API key from environment or port-forward + let sonarr_url = std::env::var("SONARR_URL") + .unwrap_or_else(|_| format!("http://{}:{}", SONARR_SERVICE_NAME, SONARR_SERVICE_PORT)); + let sonarr_api_key = std::env::var("SONARR_API_KEY") + .expect("SONARR_API_KEY environment variable must be set for E2E tests"); + + let sonarr = super::SonarrTestClient::new(&sonarr_url, &sonarr_api_key)?; + + Ok(Self { + client, + sonarr, + cleanup_resources: Vec::new(), + }) + } + + /// Register a resource for cleanup at the end of the test + pub fn register_cleanup(&mut self, kind: &str, namespace: &str, name: &str) { + self.cleanup_resources.push(CleanupResource { + kind: kind.to_string(), + namespace: namespace.to_string(), + name: name.to_string(), + }); + } + + /// Cleanup all registered resources (called automatically on drop or explicitly) + pub async fn cleanup(&mut self) { + use sonarr_operator::crds::*; + + // Cleanup in reverse order (dependencies last) + for resource in self.cleanup_resources.drain(..).rev() { + tracing::info!( + "Cleaning up {} {}/{}", + resource.kind, + resource.namespace, + resource.name + ); + + let result = match resource.kind.as_str() { + "SonarrTag" => { + delete_resource::(&self.client, &resource.namespace, &resource.name) + .await + } + "SonarrRootFolder" => { + delete_resource::( + &self.client, + &resource.namespace, + &resource.name, + ) + .await + } + "SonarrQualityProfile" => { + delete_resource::( + &self.client, + &resource.namespace, + &resource.name, + ) + .await + } + "SonarrQualityDefinition" => { + delete_resource::( + &self.client, + &resource.namespace, + &resource.name, + ) + .await + } + "SonarrAutoTag" => { + delete_resource::( + &self.client, + &resource.namespace, + &resource.name, + ) + .await + } + "SonarrCustomFormat" => { + delete_resource::( + &self.client, + &resource.namespace, + &resource.name, + ) + .await + } + "SonarrDelayProfile" => { + delete_resource::( + &self.client, + &resource.namespace, + &resource.name, + ) + .await + } + "SonarrNotification" => { + delete_resource::( + &self.client, + &resource.namespace, + &resource.name, + ) + .await + } + "SonarrIndexer" => { + delete_resource::( + &self.client, + &resource.namespace, + &resource.name, + ) + .await + } + "SonarrDownloadClient" => { + delete_resource::( + &self.client, + &resource.namespace, + &resource.name, + ) + .await + } + "SonarrMediaManagementConfig" => { + delete_resource::( + &self.client, + &resource.namespace, + &resource.name, + ) + .await + } + "SonarrNamingConfig" => { + delete_resource::( + &self.client, + &resource.namespace, + &resource.name, + ) + .await + } + "SonarrIndexerConfig" => { + delete_resource::( + &self.client, + &resource.namespace, + &resource.name, + ) + .await + } + "SonarrDownloadClientConfig" => { + delete_resource::( + &self.client, + &resource.namespace, + &resource.name, + ) + .await + } + "SonarrImportList" => { + delete_resource::( + &self.client, + &resource.namespace, + &resource.name, + ) + .await + } + "SonarrSeries" => { + delete_resource::( + &self.client, + &resource.namespace, + &resource.name, + ) + .await + } + "SonarrMetadata" => { + delete_resource::( + &self.client, + &resource.namespace, + &resource.name, + ) + .await + } + "SonarrLanguageProfile" => { + delete_resource::( + &self.client, + &resource.namespace, + &resource.name, + ) + .await + } + _ => { + tracing::warn!("Unknown resource kind for cleanup: {}", resource.kind); + Ok(()) + } + }; + + if let Err(e) = result { + tracing::warn!( + "Failed to cleanup {} {}: {:?}", + resource.kind, + resource.name, + e + ); + } + } + } +} + +impl Drop for TestContext { + fn drop(&mut self) { + // Note: async cleanup in drop is tricky, recommend calling cleanup() explicitly + if !self.cleanup_resources.is_empty() { + tracing::warn!( + "TestContext dropped with {} resources not cleaned up. Call cleanup() explicitly.", + self.cleanup_resources.len() + ); + } + } +} + +// Implement HasConditions for our CRDs +macro_rules! impl_has_conditions { + ($type:ty) => { + impl HasConditions for $type { + fn is_ready(&self) -> bool { + self.status + .as_ref() + .map(|s| { + s.conditions + .iter() + .any(|c| c.type_ == "Ready" && c.status == "True") + }) + .unwrap_or(false) + } + + fn get_error_message(&self) -> Option { + self.status.as_ref().and_then(|s| { + s.conditions + .iter() + .find(|c| c.type_ == "Ready" && c.status == "False") + .map(|c| c.message.clone()) + }) + } + } + }; +} + +use sonarr_operator::crds::*; + +impl_has_conditions!(SonarrTag); +impl_has_conditions!(SonarrRootFolder); +impl_has_conditions!(SonarrQualityProfile); +impl_has_conditions!(SonarrAutoTag); +impl_has_conditions!(SonarrCustomFormat); +impl_has_conditions!(SonarrDelayProfile); +impl_has_conditions!(SonarrNotification); +impl_has_conditions!(SonarrIndexer); +impl_has_conditions!(SonarrDownloadClient); +impl_has_conditions!(SonarrMediaManagementConfig); +impl_has_conditions!(SonarrNamingConfig); +impl_has_conditions!(SonarrIndexerConfig); +impl_has_conditions!(SonarrDownloadClientConfig); +impl_has_conditions!(SonarrQualityDefinition); +impl_has_conditions!(SonarrImportList); +impl_has_conditions!(SonarrSeries); +impl_has_conditions!(SonarrMetadata); +impl_has_conditions!(SonarrLanguageProfile); diff --git a/tests/e2e/fixtures/sonarr-instance.yaml b/tests/e2e/fixtures/sonarr-instance.yaml new file mode 100644 index 0000000..3b90d4b --- /dev/null +++ b/tests/e2e/fixtures/sonarr-instance.yaml @@ -0,0 +1,25 @@ +# Sonarr CRD instance for E2E tests +# +# The operator reconciles this CR to create: +# - A Deployment running Sonarr (with init container for config.xml) +# - A NodePort Service exposed on port 30989 +# - A PVC for Sonarr configuration storage +# +# Prerequisites: +# - The API key Secret must be created before applying this CR +# - The k3d cluster must map host port 8989 to node port 30989 +--- +apiVersion: devopsarr.io/v1alpha1 +kind: Sonarr +metadata: + name: sonarr + namespace: default +spec: + apiKeySecretRef: + name: sonarr-api-key + key: api-key + service: + serviceType: NodePort + nodePort: 30989 + config: + authenticationMethod: None diff --git a/tests/e2e/main.rs b/tests/e2e/main.rs new file mode 100644 index 0000000..de13ab9 --- /dev/null +++ b/tests/e2e/main.rs @@ -0,0 +1,23 @@ +//! End-to-End tests for the Sonarr Kubernetes Operator +//! +//! These tests verify the full reconciliation loop by: +//! 1. Running the operator against a real Kubernetes cluster +//! 2. Creating CRs and verifying resources are created in Sonarr +//! 3. Testing cross-resource dependencies +//! 4. Verifying cleanup on deletion +//! +//! Prerequisites: +//! 1. A running Kubernetes cluster (e.g., kind, k3d) +//! 2. CRDs installed: `make install` +//! 3. Sonarr instance deployed: `make e2e-sonarr` +//! 4. Operator running: `make run` (in background) +//! +//! Run with: `cargo test --test e2e -- --ignored --test-threads=1` + +mod common; +mod scenarios; +mod sonarr_client; + +// Re-export for use in scenario tests +pub use common::*; +pub use sonarr_client::SonarrTestClient; diff --git a/tests/e2e/scenarios/config_resources.rs b/tests/e2e/scenarios/config_resources.rs new file mode 100644 index 0000000..f2ae720 --- /dev/null +++ b/tests/e2e/scenarios/config_resources.rs @@ -0,0 +1,310 @@ +//! E2E tests for config resources (singleton resources) +//! +//! Tests for MediaManagementConfig, NamingConfig, IndexerConfig, DownloadClientConfig + +use crate::common::*; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::api::{Api, Patch, PatchParams}; +use sonarr_operator::crds::*; +use std::time::Duration; + +/// Test MediaManagementConfig updates Sonarr settings +#[tokio::test] +#[ignore = "requires E2E environment - run with: cargo test --test e2e -- --ignored"] +async fn test_media_management_config_update() { + let mut ctx = TestContext::new() + .await + .expect("Failed to create test context"); + setup_e2e_namespace(&ctx.client) + .await + .expect("Failed to setup namespace"); + + // Get current config from Sonarr for comparison + let original_config = ctx + .sonarr + .get_media_management_config() + .await + .expect("Failed to get current media management config"); + tracing::info!( + "Original config: recycle_bin_cleanup_days = {}", + original_config.recycle_bin_cleanup_days + ); + + // Create MediaManagementConfig CR + let config_name = unique_name("e2e-mmc"); + ctx.register_cleanup("SonarrMediaManagementConfig", E2E_NAMESPACE, &config_name); + + let new_cleanup_days = if original_config.recycle_bin_cleanup_days == 7 { + 14 + } else { + 7 + }; + + let config = SonarrMediaManagementConfig { + metadata: ObjectMeta { + name: Some(config_name.clone()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrMediaManagementConfigSpec { + recycle_bin_cleanup_days: Some(new_cleanup_days), + create_empty_series_folders: Some(true), + delete_empty_folders: Some(true), + sonarr_instance_ref: SonarrInstanceRef { + name: "sonarr".to_string(), + namespace: Some("default".to_string()), + }, + ..Default::default() + }, + status: None, + }; + + apply_resource(&ctx.client, &config) + .await + .expect("Failed to create config"); + + // Wait for Ready + wait_for_ready::( + &ctx.client, + E2E_NAMESPACE, + &config_name, + E2E_TIMEOUT, + ) + .await + .expect("Config never became ready"); + + // Give Sonarr time to apply the change + tokio::time::sleep(Duration::from_secs(3)).await; + + // Verify in Sonarr + let updated_config = ctx + .sonarr + .get_media_management_config() + .await + .expect("Failed to get updated config"); + + assert_eq!( + updated_config.recycle_bin_cleanup_days, new_cleanup_days, + "Recycle bin cleanup days should be updated" + ); + assert!( + updated_config.create_empty_series_folders, + "Create empty folders should be true" + ); + assert!( + updated_config.delete_empty_folders, + "Delete empty folders should be true" + ); + tracing::info!("✓ MediaManagementConfig verified in Sonarr"); + + // Restore original value + let api: Api = Api::namespaced(ctx.client.clone(), E2E_NAMESPACE); + let restore_patch = serde_json::json!({ + "apiVersion": "devopsarr.io/v1alpha1", + "kind": "SonarrMediaManagementConfig", + "metadata": { + "name": config_name, + "namespace": E2E_NAMESPACE + }, + "spec": { + "recycleBinCleanupDays": original_config.recycle_bin_cleanup_days, + "sonarrInstanceRef": { + "name": "sonarr", + "namespace": "default" + } + } + }); + + api.patch( + &config_name, + &PatchParams::apply("sonarr-e2e-test").force(), + &Patch::Apply(&restore_patch), + ) + .await + .expect("Failed to restore config"); + + tokio::time::sleep(Duration::from_secs(3)).await; + tracing::info!("✓ Config restored to original values"); + + ctx.cleanup().await; +} + +/// Test NamingConfig updates episode naming settings +#[tokio::test] +#[ignore = "requires E2E environment - run with: cargo test --test e2e -- --ignored"] +async fn test_naming_config_update() { + let mut ctx = TestContext::new() + .await + .expect("Failed to create test context"); + setup_e2e_namespace(&ctx.client) + .await + .expect("Failed to setup namespace"); + + // Get current config + let original_config = ctx + .sonarr + .get_naming_config() + .await + .expect("Failed to get current naming config"); + tracing::info!( + "Original config: rename_episodes = {}", + original_config.rename_episodes + ); + + let config_name = unique_name("e2e-nc"); + ctx.register_cleanup("SonarrNamingConfig", E2E_NAMESPACE, &config_name); + + // Create config with specific naming format + let config = SonarrNamingConfig { + metadata: ObjectMeta { + name: Some(config_name.clone()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrNamingConfigSpec { + rename_episodes: Some(true), + replace_illegal_characters: Some(true), + standard_episode_format: Some( + "{Series Title} - S{season:00}E{episode:00} - {Episode Title}".to_string(), + ), + season_folder_format: Some("Season {season}".to_string()), + sonarr_instance_ref: SonarrInstanceRef { + name: "sonarr".to_string(), + namespace: Some("default".to_string()), + }, + ..Default::default() + }, + status: None, + }; + + apply_resource(&ctx.client, &config) + .await + .expect("Failed to create naming config"); + + wait_for_ready::(&ctx.client, E2E_NAMESPACE, &config_name, E2E_TIMEOUT) + .await + .expect("NamingConfig never became ready"); + + tokio::time::sleep(Duration::from_secs(3)).await; + + let updated_config = ctx + .sonarr + .get_naming_config() + .await + .expect("Failed to get updated naming config"); + + assert!( + updated_config.rename_episodes, + "Rename episodes should be enabled" + ); + assert!( + updated_config.replace_illegal_characters, + "Replace illegal chars should be enabled" + ); + tracing::info!("✓ NamingConfig verified in Sonarr"); + + ctx.cleanup().await; +} + +/// Test singleton constraint - only one config per instance +#[tokio::test] +#[ignore = "requires E2E environment - run with: cargo test --test e2e -- --ignored"] +async fn test_config_singleton_constraint() { + let mut ctx = TestContext::new() + .await + .expect("Failed to create test context"); + setup_e2e_namespace(&ctx.client) + .await + .expect("Failed to setup namespace"); + + // Create first MediaManagementConfig + let config1_name = unique_name("e2e-mmc-1"); + ctx.register_cleanup("SonarrMediaManagementConfig", E2E_NAMESPACE, &config1_name); + + let config1 = SonarrMediaManagementConfig { + metadata: ObjectMeta { + name: Some(config1_name.clone()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrMediaManagementConfigSpec { + recycle_bin_cleanup_days: Some(7), + sonarr_instance_ref: SonarrInstanceRef { + name: "sonarr".to_string(), + namespace: Some("default".to_string()), + }, + ..Default::default() + }, + status: None, + }; + + apply_resource(&ctx.client, &config1) + .await + .expect("Failed to create first config"); + + wait_for_ready::( + &ctx.client, + E2E_NAMESPACE, + &config1_name, + E2E_TIMEOUT, + ) + .await + .expect("First config never became ready"); + + tracing::info!("First config created successfully"); + + // Try to create second config for same instance + let config2_name = unique_name("e2e-mmc-2"); + ctx.register_cleanup("SonarrMediaManagementConfig", E2E_NAMESPACE, &config2_name); + + let config2 = SonarrMediaManagementConfig { + metadata: ObjectMeta { + name: Some(config2_name.clone()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrMediaManagementConfigSpec { + recycle_bin_cleanup_days: Some(14), + sonarr_instance_ref: SonarrInstanceRef { + name: "sonarr".to_string(), // Same instance + namespace: Some("default".to_string()), + }, + ..Default::default() + }, + status: None, + }; + + apply_resource(&ctx.client, &config2) + .await + .expect("Failed to create second config CR"); + + // Wait a bit for operator to process + tokio::time::sleep(Duration::from_secs(10)).await; + + // Check that second config has a conflict/error condition + let api: Api = Api::namespaced(ctx.client.clone(), E2E_NAMESPACE); + let config2_status = api + .get(&config2_name) + .await + .expect("Failed to get second config"); + + if let Some(status) = &config2_status.status { + let has_conflict = status + .conditions + .iter() + .any(|c| (c.type_ == "Ready" && c.status == "False") || c.type_ == "Conflict"); + + if has_conflict { + tracing::info!( + "✓ Singleton constraint enforced - second config has error/conflict condition" + ); + } else { + // The operator might handle this differently + tracing::warn!( + "Second config doesn't show conflict - singleton may not be enforced at controller level" + ); + } + } + + ctx.cleanup().await; +} diff --git a/tests/e2e/scenarios/cross_resource_dependencies.rs b/tests/e2e/scenarios/cross_resource_dependencies.rs new file mode 100644 index 0000000..2957237 --- /dev/null +++ b/tests/e2e/scenarios/cross_resource_dependencies.rs @@ -0,0 +1,536 @@ +//! E2E tests for cross-resource dependencies +//! +//! Tests scenarios where resources depend on each other: +//! - AutoTag depending on Tags +//! - QualityProfile depending on CustomFormats +//! - DelayProfile depending on Tags +//! - Notifications with Tags + +use crate::common::*; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::api::{Api, DeleteParams}; +use sonarr_operator::crds::auto_tag::{AutoTagFields, AutoTagImplementation, AutoTagSpecification}; +use sonarr_operator::crds::custom_format::{ + CustomFormatFields, CustomFormatImplementation, CustomFormatSpecification, +}; +use sonarr_operator::crds::delay_profile::DownloadProtocol; +use sonarr_operator::crds::{ + SonarrAutoTag, SonarrAutoTagSpec, SonarrCustomFormat, SonarrCustomFormatSpec, + SonarrDelayProfile, SonarrDelayProfileSpec, SonarrInstanceRef, SonarrTag, SonarrTagSpec, +}; +use std::time::Duration; + +/// Test AutoTag that references Tags +/// AutoTags assign tags to series based on conditions, so they need existing tags +#[tokio::test] +#[ignore = "requires E2E environment - run with: cargo test --test e2e -- --ignored"] +async fn test_autotag_with_tag_dependency() { + let mut ctx = TestContext::new() + .await + .expect("Failed to create test context"); + setup_e2e_namespace(&ctx.client) + .await + .expect("Failed to setup namespace"); + + // Step 1: Create the dependent tag first + let tag_name = unique_name("e2e-dep-tag"); + let tag_label = format!("dependency-{}", tag_name); + ctx.register_cleanup("SonarrTag", E2E_NAMESPACE, &tag_name); + + tracing::info!("Step 1: Creating dependency tag: {}", tag_name); + let tag = SonarrTag { + metadata: ObjectMeta { + name: Some(tag_name.clone()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrTagSpec { + label: tag_label.clone(), + sonarr_instance_ref: SonarrInstanceRef { + name: "sonarr".to_string(), + namespace: Some("default".to_string()), + }, + }, + status: None, + }; + + apply_resource(&ctx.client, &tag) + .await + .expect("Failed to create tag"); + + // Wait for tag to be ready and get its ID + let ready_tag = wait_for_ready::(&ctx.client, E2E_NAMESPACE, &tag_name, E2E_TIMEOUT) + .await + .expect("Tag never became ready"); + + let tag_id = ready_tag + .status + .as_ref() + .and_then(|s| s.id) + .expect("Tag should have ID"); + tracing::info!("Tag created with ID: {}", tag_id); + + // Verify tag exists in Sonarr + let sonarr_tag = ctx + .sonarr + .find_tag_by_label(&tag_label) + .await + .expect("Failed to query Sonarr") + .expect("Tag not found in Sonarr"); + assert_eq!(sonarr_tag.id, tag_id); + tracing::info!("✓ Dependency tag verified in Sonarr"); + + // Step 2: Create AutoTag that uses the tag + let autotag_name = unique_name("e2e-autotag"); + ctx.register_cleanup("SonarrAutoTag", E2E_NAMESPACE, &autotag_name); + + tracing::info!( + "Step 2: Creating AutoTag that references tag ID: {}", + tag_id + ); + let autotag = SonarrAutoTag { + metadata: ObjectMeta { + name: Some(autotag_name.clone()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrAutoTagSpec { + name: format!("E2E AutoTag {}", autotag_name), + remove_tags_automatically: false, + tags: vec![tag_id], // Reference the tag we created + specifications: vec![AutoTagSpecification { + name: "Root Folder".to_string(), + implementation: AutoTagImplementation::RootFolderSpecification, + negate: false, + required: true, + fields: AutoTagFields { + value: Some("/config".to_string()), + min: None, + max: None, + }, + }], + sonarr_instance_ref: SonarrInstanceRef { + name: "sonarr".to_string(), + namespace: Some("default".to_string()), + }, + }, + status: None, + }; + + apply_resource(&ctx.client, &autotag) + .await + .expect("Failed to create autotag"); + + // Wait for autotag to be ready + let ready_autotag = + wait_for_ready::(&ctx.client, E2E_NAMESPACE, &autotag_name, E2E_TIMEOUT) + .await + .expect("AutoTag never became ready"); + + let autotag_id = ready_autotag + .status + .as_ref() + .and_then(|s| s.id) + .expect("AutoTag should have ID"); + tracing::info!("AutoTag created with ID: {}", autotag_id); + + // Verify in Sonarr and check the tag reference + let sonarr_autotag = ctx + .sonarr + .find_auto_tag_by_name(&format!("E2E AutoTag {}", autotag_name)) + .await + .expect("Failed to query Sonarr") + .expect("AutoTag not found in Sonarr"); + + assert_eq!(sonarr_autotag.id, autotag_id); + assert!( + sonarr_autotag.tags.contains(&tag_id), + "AutoTag should reference tag ID {}. Actual tags: {:?}", + tag_id, + sonarr_autotag.tags + ); + tracing::info!("✓ AutoTag verified with correct tag reference"); + + // Step 3: Test cascade behavior - delete resources in correct order + // AutoTag first (dependent), then Tag (dependency) + tracing::info!("Step 3: Testing deletion order..."); + + let autotag_api: Api = Api::namespaced(ctx.client.clone(), E2E_NAMESPACE); + autotag_api + .delete(&autotag_name, &DeleteParams::default()) + .await + .expect("Failed to delete autotag"); + + wait_for_deletion::(&ctx.client, E2E_NAMESPACE, &autotag_name, QUICK_TIMEOUT) + .await + .expect("AutoTag not deleted"); + + tokio::time::sleep(Duration::from_secs(2)).await; + + // Now delete the tag + let tag_api: Api = Api::namespaced(ctx.client.clone(), E2E_NAMESPACE); + tag_api + .delete(&tag_name, &DeleteParams::default()) + .await + .expect("Failed to delete tag"); + + wait_for_deletion::(&ctx.client, E2E_NAMESPACE, &tag_name, QUICK_TIMEOUT) + .await + .expect("Tag not deleted"); + + tracing::info!("✓ Cross-resource dependency test completed successfully"); + ctx.cleanup().await; +} + +/// Test DelayProfile that references Tags +#[tokio::test] +#[ignore = "requires E2E environment - run with: cargo test --test e2e -- --ignored"] +async fn test_delay_profile_with_tag_dependency() { + let mut ctx = TestContext::new() + .await + .expect("Failed to create test context"); + setup_e2e_namespace(&ctx.client) + .await + .expect("Failed to setup namespace"); + + // Create dependency tag + let tag_name = unique_name("e2e-delay-tag"); + let tag_label = format!("delay-dep-{}", tag_name); + ctx.register_cleanup("SonarrTag", E2E_NAMESPACE, &tag_name); + + let tag = SonarrTag { + metadata: ObjectMeta { + name: Some(tag_name.clone()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrTagSpec { + label: tag_label.clone(), + sonarr_instance_ref: SonarrInstanceRef { + name: "sonarr".to_string(), + namespace: Some("default".to_string()), + }, + }, + status: None, + }; + + apply_resource(&ctx.client, &tag) + .await + .expect("Failed to create tag"); + let ready_tag = wait_for_ready::(&ctx.client, E2E_NAMESPACE, &tag_name, E2E_TIMEOUT) + .await + .expect("Tag never became ready"); + let tag_id = ready_tag + .status + .as_ref() + .and_then(|s| s.id) + .expect("Tag should have ID"); + tracing::info!("Dependency tag created with ID: {}", tag_id); + + // Create DelayProfile with tag + let dp_name = unique_name("e2e-delay"); + ctx.register_cleanup("SonarrDelayProfile", E2E_NAMESPACE, &dp_name); + + let delay_profile = SonarrDelayProfile { + metadata: ObjectMeta { + name: Some(dp_name.clone()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrDelayProfileSpec { + enable_usenet: true, + enable_torrent: true, + preferred_protocol: DownloadProtocol::Usenet, + usenet_delay: 60, + torrent_delay: 120, + tags: vec![tag_id], + bypass_if_highest_quality: false, + bypass_if_above_custom_format_score: false, + minimum_custom_format_score: 0, + order: 0, + sonarr_instance_ref: SonarrInstanceRef { + name: "sonarr".to_string(), + namespace: Some("default".to_string()), + }, + }, + status: None, + }; + + apply_resource(&ctx.client, &delay_profile) + .await + .expect("Failed to create delay profile"); + + let ready_dp = + wait_for_ready::(&ctx.client, E2E_NAMESPACE, &dp_name, E2E_TIMEOUT) + .await + .expect("DelayProfile never became ready"); + + let dp_id = ready_dp + .status + .as_ref() + .and_then(|s| s.id) + .expect("DelayProfile should have ID"); + tracing::info!("DelayProfile created with ID: {}", dp_id); + + // Verify delay profile has the tag + let delay_profiles = ctx + .sonarr + .get_delay_profiles() + .await + .expect("Failed to get delay profiles"); + let our_dp = delay_profiles.iter().find(|dp| dp.id == dp_id); + + assert!(our_dp.is_some(), "DelayProfile not found in Sonarr"); + let our_dp = our_dp.unwrap(); + assert!( + our_dp.tags.contains(&tag_id), + "DelayProfile should have tag {}. Actual: {:?}", + tag_id, + our_dp.tags + ); + tracing::info!("✓ DelayProfile verified with tag reference"); + + ctx.cleanup().await; +} + +/// Test that creating a resource with invalid tag reference fails gracefully +#[tokio::test] +#[ignore = "requires E2E environment - run with: cargo test --test e2e -- --ignored"] +async fn test_invalid_tag_reference_handling() { + let mut ctx = TestContext::new() + .await + .expect("Failed to create test context"); + setup_e2e_namespace(&ctx.client) + .await + .expect("Failed to setup namespace"); + + // Create AutoTag with a non-existent tag ID + let autotag_name = unique_name("e2e-invalid-ref"); + ctx.register_cleanup("SonarrAutoTag", E2E_NAMESPACE, &autotag_name); + + let autotag = SonarrAutoTag { + metadata: ObjectMeta { + name: Some(autotag_name.clone()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrAutoTagSpec { + name: format!("E2E Invalid Ref {}", autotag_name), + remove_tags_automatically: false, + tags: vec![99999], // Non-existent tag ID + specifications: vec![AutoTagSpecification { + name: "Root Folder".to_string(), + implementation: AutoTagImplementation::RootFolderSpecification, + negate: false, + required: true, + fields: AutoTagFields { + value: Some("/config".to_string()), + min: None, + max: None, + }, + }], + sonarr_instance_ref: SonarrInstanceRef { + name: "sonarr".to_string(), + namespace: Some("default".to_string()), + }, + }, + status: None, + }; + + apply_resource(&ctx.client, &autotag) + .await + .expect("Failed to create autotag CR"); + + // The resource should be created in K8s but may have an error condition + // Wait a bit for the operator to process it + tokio::time::sleep(Duration::from_secs(10)).await; + + // Check if the resource has an error condition or was created anyway + // (Sonarr API behavior varies - it might accept invalid tag IDs or reject them) + let api: Api = Api::namespaced(ctx.client.clone(), E2E_NAMESPACE); + let result = api.get(&autotag_name).await; + + if let Ok(autotag) = result + && let Some(status) = &autotag.status + { + let has_error = status + .conditions + .iter() + .any(|c| c.type_ == "Ready" && c.status == "False"); + + if has_error { + tracing::info!("✓ AutoTag correctly shows error for invalid tag reference"); + } else { + // Some Sonarr versions accept invalid tag IDs + tracing::warn!( + "AutoTag was created despite invalid tag reference - Sonarr may accept any tag ID" + ); + } + } + + ctx.cleanup().await; +} + +/// Test multiple resources created in dependency order +#[tokio::test] +#[ignore = "requires E2E environment - run with: cargo test --test e2e -- --ignored"] +async fn test_full_dependency_chain() { + let mut ctx = TestContext::new() + .await + .expect("Failed to create test context"); + setup_e2e_namespace(&ctx.client) + .await + .expect("Failed to setup namespace"); + + let suffix = unique_name("chain"); + + // Level 1: Create Tags (no dependencies) + let tag_names: Vec<_> = (1..=2) + .map(|i| format!("chain-tag-{}-{}", i, suffix)) + .collect(); + let mut tag_ids = Vec::new(); + + for tag_name in &tag_names { + ctx.register_cleanup("SonarrTag", E2E_NAMESPACE, tag_name); + let tag = SonarrTag { + metadata: ObjectMeta { + name: Some(tag_name.clone()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrTagSpec { + label: tag_name.clone(), + sonarr_instance_ref: SonarrInstanceRef { + name: "sonarr".to_string(), + namespace: Some("default".to_string()), + }, + }, + status: None, + }; + apply_resource(&ctx.client, &tag) + .await + .expect("Failed to create tag"); + } + + // Wait for all tags + for tag_name in &tag_names { + let ready = wait_for_ready::(&ctx.client, E2E_NAMESPACE, tag_name, E2E_TIMEOUT) + .await + .expect("Tag not ready"); + tag_ids.push(ready.status.as_ref().and_then(|s| s.id).unwrap()); + } + tracing::info!("Level 1: Created {} tags: {:?}", tag_names.len(), tag_ids); + + // Level 2: Create CustomFormat (no dependencies) + let cf_name = format!("chain-cf-{}", suffix); + ctx.register_cleanup("SonarrCustomFormat", E2E_NAMESPACE, &cf_name); + + let custom_format = SonarrCustomFormat { + metadata: ObjectMeta { + name: Some(cf_name.clone()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrCustomFormatSpec { + name: cf_name.clone(), + include_custom_format_when_renaming: false, + specifications: vec![CustomFormatSpecification { + name: "Test Regex".to_string(), + implementation: CustomFormatImplementation::ReleaseTitleSpecification, + negate: false, + required: true, + fields: CustomFormatFields { + value: Some("e2e-chain-test".to_string()), + min: None, + max: None, + }, + }], + sonarr_instance_ref: SonarrInstanceRef { + name: "sonarr".to_string(), + namespace: Some("default".to_string()), + }, + }, + status: None, + }; + apply_resource(&ctx.client, &custom_format) + .await + .expect("Failed to create custom format"); + + let ready_cf = + wait_for_ready::(&ctx.client, E2E_NAMESPACE, &cf_name, E2E_TIMEOUT) + .await + .expect("CustomFormat not ready"); + let cf_id = ready_cf.status.as_ref().and_then(|s| s.id).unwrap(); + tracing::info!("Level 2: Created CustomFormat with ID: {}", cf_id); + + // Level 3: Create DelayProfile (depends on Tags) + let dp_name = format!("chain-dp-{}", suffix); + ctx.register_cleanup("SonarrDelayProfile", E2E_NAMESPACE, &dp_name); + + let delay_profile = SonarrDelayProfile { + metadata: ObjectMeta { + name: Some(dp_name.clone()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrDelayProfileSpec { + enable_usenet: true, + enable_torrent: false, + preferred_protocol: DownloadProtocol::Usenet, + usenet_delay: 30, + torrent_delay: 0, + tags: tag_ids.clone(), + bypass_if_highest_quality: false, + bypass_if_above_custom_format_score: false, + minimum_custom_format_score: 0, + order: 0, + sonarr_instance_ref: SonarrInstanceRef { + name: "sonarr".to_string(), + namespace: Some("default".to_string()), + }, + }, + status: None, + }; + apply_resource(&ctx.client, &delay_profile) + .await + .expect("Failed to create delay profile"); + + let ready_dp = + wait_for_ready::(&ctx.client, E2E_NAMESPACE, &dp_name, E2E_TIMEOUT) + .await + .expect("DelayProfile not ready"); + let dp_id = ready_dp.status.as_ref().and_then(|s| s.id).unwrap(); + tracing::info!( + "Level 3: Created DelayProfile with ID: {} referencing tags: {:?}", + dp_id, + tag_ids + ); + + // Verify the full chain in Sonarr + let sonarr_tags = ctx.sonarr.get_tags().await.expect("Failed to get tags"); + for tag_id in &tag_ids { + assert!( + sonarr_tags.iter().any(|t| t.id == *tag_id), + "Tag {} not found in Sonarr", + tag_id + ); + } + + let sonarr_cf = ctx + .sonarr + .find_custom_format_by_name(&cf_name) + .await + .expect("Failed to get CF"); + assert!(sonarr_cf.is_some(), "CustomFormat not found in Sonarr"); + + let delay_profiles = ctx + .sonarr + .get_delay_profiles() + .await + .expect("Failed to get delay profiles"); + let our_dp = delay_profiles.iter().find(|dp| dp.id == dp_id); + assert!(our_dp.is_some(), "DelayProfile not found in Sonarr"); + + tracing::info!("✓ Full dependency chain verified successfully"); + ctx.cleanup().await; +} diff --git a/tests/e2e/scenarios/custom_format_lifecycle.rs b/tests/e2e/scenarios/custom_format_lifecycle.rs new file mode 100644 index 0000000..2c1af0d --- /dev/null +++ b/tests/e2e/scenarios/custom_format_lifecycle.rs @@ -0,0 +1,109 @@ +//! E2E tests for CustomFormat lifecycle + +use crate::common::*; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::api::{Api, DeleteParams}; +use sonarr_operator::crds::custom_format::{ + CustomFormatFields, CustomFormatImplementation, CustomFormatSpecification, +}; +use sonarr_operator::crds::{SonarrCustomFormat, SonarrCustomFormatSpec, SonarrInstanceRef}; +use std::time::Duration; + +/// Test the full lifecycle of a CustomFormat +#[tokio::test] +#[ignore = "requires E2E environment - run with: cargo test --test e2e -- --ignored"] +async fn test_custom_format_full_lifecycle() { + let mut ctx = TestContext::new() + .await + .expect("Failed to create test context"); + setup_e2e_namespace(&ctx.client) + .await + .expect("Failed to setup namespace"); + + let cf_name = unique_name("e2e-cf"); + let format_name = format!("E2E Custom Format {}", cf_name); + + ctx.register_cleanup("SonarrCustomFormat", E2E_NAMESPACE, &cf_name); + + // Create the custom format CR + tracing::info!("Creating SonarrCustomFormat: {}", cf_name); + let custom_format = SonarrCustomFormat { + metadata: ObjectMeta { + name: Some(cf_name.clone()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrCustomFormatSpec { + name: format_name.clone(), + include_custom_format_when_renaming: false, + specifications: vec![CustomFormatSpecification { + name: "Test Regex".to_string(), + implementation: CustomFormatImplementation::ReleaseTitleSpecification, + negate: false, + required: true, + fields: CustomFormatFields { + value: Some("e2e-test-pattern".to_string()), + min: None, + max: None, + }, + }], + sonarr_instance_ref: SonarrInstanceRef { + name: "sonarr".to_string(), + namespace: Some("default".to_string()), + }, + }, + status: None, + }; + + apply_resource(&ctx.client, &custom_format) + .await + .expect("Failed to create custom format CR"); + + // Wait for Ready condition + tracing::info!("Waiting for custom format to be ready..."); + let ready_cf = + wait_for_ready::(&ctx.client, E2E_NAMESPACE, &cf_name, E2E_TIMEOUT) + .await + .expect("Custom format never became ready"); + + let cf_id = ready_cf + .status + .as_ref() + .and_then(|s| s.id) + .expect("Custom format should have an ID"); + tracing::info!("Custom format created with ID: {}", cf_id); + + // Verify in Sonarr API + tracing::info!("Verifying custom format exists in Sonarr..."); + let sonarr_cf = ctx + .sonarr + .find_custom_format_by_name(&format_name) + .await + .expect("Failed to query Sonarr API") + .expect("Custom format not found in Sonarr"); + + assert_eq!(sonarr_cf.id, cf_id, "Custom format ID mismatch"); + assert_eq!(sonarr_cf.name, format_name, "Custom format name mismatch"); + tracing::info!("✓ Custom format verified in Sonarr"); + + // Delete and verify cleanup + let api: Api = Api::namespaced(ctx.client.clone(), E2E_NAMESPACE); + api.delete(&cf_name, &DeleteParams::default()) + .await + .expect("Failed to delete custom format CR"); + + wait_for_deletion::(&ctx.client, E2E_NAMESPACE, &cf_name, QUICK_TIMEOUT) + .await + .expect("Custom format CR was not deleted"); + + tokio::time::sleep(Duration::from_secs(3)).await; + + let deleted = ctx.sonarr.find_custom_format_by_name(&format_name).await; + assert!( + matches!(deleted, Ok(None)), + "Custom format should be deleted from Sonarr" + ); + tracing::info!("✓ Custom format deleted from Sonarr"); + + ctx.cleanup().await; +} diff --git a/tests/e2e/scenarios/download_client_config_lifecycle.rs b/tests/e2e/scenarios/download_client_config_lifecycle.rs new file mode 100644 index 0000000..1942e45 --- /dev/null +++ b/tests/e2e/scenarios/download_client_config_lifecycle.rs @@ -0,0 +1,226 @@ +//! E2E tests for DownloadClientConfig resource +//! +//! Tests global download client configuration management via the SonarrDownloadClientConfig CRD. + +use crate::common::*; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::api::{Api, Patch, PatchParams}; +use sonarr_operator::crds::*; +use std::time::Duration; + +/// Test DownloadClientConfig updates global download client settings in Sonarr +#[tokio::test] +#[ignore = "requires E2E environment - run with: cargo test --test e2e -- --ignored"] +async fn test_download_client_config_update() { + let mut ctx = TestContext::new() + .await + .expect("Failed to create test context"); + setup_e2e_namespace(&ctx.client) + .await + .expect("Failed to setup namespace"); + + // Get current config from Sonarr for comparison + let original_config = ctx + .sonarr + .get_download_client_config() + .await + .expect("Failed to get current download client config"); + tracing::info!( + "Original config: enable_completed_download_handling = {}, auto_redownload_failed = {}", + original_config.enable_completed_download_handling, + original_config.auto_redownload_failed + ); + + // Create DownloadClientConfig CR + let config_name = unique_name("e2e-dcc"); + ctx.register_cleanup("SonarrDownloadClientConfig", E2E_NAMESPACE, &config_name); + + let new_completed_handling = !original_config.enable_completed_download_handling; + let new_auto_redownload = !original_config.auto_redownload_failed; + + let config = SonarrDownloadClientConfig { + metadata: ObjectMeta { + name: Some(config_name.clone()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrDownloadClientConfigSpec { + enable_completed_download_handling: Some(new_completed_handling), + auto_redownload_failed: Some(new_auto_redownload), + sonarr_instance_ref: SonarrInstanceRef { + name: "sonarr".to_string(), + namespace: Some("default".to_string()), + }, + ..Default::default() + }, + status: None, + }; + + apply_resource(&ctx.client, &config) + .await + .expect("Failed to create download client config"); + + // Wait for Ready + wait_for_ready::( + &ctx.client, + E2E_NAMESPACE, + &config_name, + E2E_TIMEOUT, + ) + .await + .expect("DownloadClientConfig never became ready"); + + // Give Sonarr time to apply the change + tokio::time::sleep(Duration::from_secs(3)).await; + + // Verify in Sonarr + let updated_config = ctx + .sonarr + .get_download_client_config() + .await + .expect("Failed to get updated download client config"); + + assert_eq!( + updated_config.enable_completed_download_handling, new_completed_handling, + "Enable completed download handling should be updated" + ); + assert_eq!( + updated_config.auto_redownload_failed, new_auto_redownload, + "Auto redownload failed should be updated" + ); + tracing::info!("✓ DownloadClientConfig verified in Sonarr"); + + // Restore original value + let api: Api = Api::namespaced(ctx.client.clone(), E2E_NAMESPACE); + let restore_patch = serde_json::json!({ + "apiVersion": "devopsarr.io/v1alpha1", + "kind": "SonarrDownloadClientConfig", + "metadata": { + "name": config_name, + "namespace": E2E_NAMESPACE + }, + "spec": { + "enableCompletedDownloadHandling": original_config.enable_completed_download_handling, + "autoRedownloadFailed": original_config.auto_redownload_failed, + "sonarrInstanceRef": { + "name": "sonarr", + "namespace": "default" + } + } + }); + + api.patch( + &config_name, + &PatchParams::apply("sonarr-e2e-test").force(), + &Patch::Apply(&restore_patch), + ) + .await + .expect("Failed to restore config"); + + tokio::time::sleep(Duration::from_secs(3)).await; + tracing::info!("✓ DownloadClientConfig restored to original values"); + + ctx.cleanup().await; +} + +/// Test DownloadClientConfig singleton constraint - only one per Sonarr instance +#[tokio::test] +#[ignore = "requires E2E environment - run with: cargo test --test e2e -- --ignored"] +async fn test_download_client_config_singleton_constraint() { + let mut ctx = TestContext::new() + .await + .expect("Failed to create test context"); + setup_e2e_namespace(&ctx.client) + .await + .expect("Failed to setup namespace"); + + // Create first DownloadClientConfig + let config1_name = unique_name("e2e-dcc-1"); + ctx.register_cleanup("SonarrDownloadClientConfig", E2E_NAMESPACE, &config1_name); + + let config1 = SonarrDownloadClientConfig { + metadata: ObjectMeta { + name: Some(config1_name.clone()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrDownloadClientConfigSpec { + enable_completed_download_handling: Some(true), + sonarr_instance_ref: SonarrInstanceRef { + name: "sonarr".to_string(), + namespace: Some("default".to_string()), + }, + ..Default::default() + }, + status: None, + }; + + apply_resource(&ctx.client, &config1) + .await + .expect("Failed to create first download client config"); + + wait_for_ready::( + &ctx.client, + E2E_NAMESPACE, + &config1_name, + E2E_TIMEOUT, + ) + .await + .expect("First download client config never became ready"); + + tracing::info!("First DownloadClientConfig created successfully"); + + // Try to create second config for same instance + let config2_name = unique_name("e2e-dcc-2"); + ctx.register_cleanup("SonarrDownloadClientConfig", E2E_NAMESPACE, &config2_name); + + let config2 = SonarrDownloadClientConfig { + metadata: ObjectMeta { + name: Some(config2_name.clone()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrDownloadClientConfigSpec { + enable_completed_download_handling: Some(false), + sonarr_instance_ref: SonarrInstanceRef { + name: "sonarr".to_string(), + namespace: Some("default".to_string()), + }, + ..Default::default() + }, + status: None, + }; + + apply_resource(&ctx.client, &config2) + .await + .expect("Failed to create second download client config CR"); + + // Wait for operator to process + tokio::time::sleep(Duration::from_secs(10)).await; + + // Check that second config has a conflict/error condition + let api: Api = Api::namespaced(ctx.client.clone(), E2E_NAMESPACE); + let config2_status = api + .get(&config2_name) + .await + .expect("Failed to get second config"); + + if let Some(status) = &config2_status.status { + let has_conflict = status + .conditions + .iter() + .any(|c| (c.type_ == "Ready" && c.status == "False") || c.type_ == "Conflict"); + + if has_conflict { + tracing::info!( + "✓ Singleton constraint enforced - second DownloadClientConfig has error/conflict condition" + ); + } else { + tracing::warn!( + "Second config doesn't show conflict - singleton may not be enforced at controller level" + ); + } + } + + ctx.cleanup().await; +} diff --git a/tests/e2e/scenarios/error_recovery.rs b/tests/e2e/scenarios/error_recovery.rs new file mode 100644 index 0000000..e089d59 --- /dev/null +++ b/tests/e2e/scenarios/error_recovery.rs @@ -0,0 +1,318 @@ +//! E2E tests for error recovery scenarios +//! +//! Tests how the operator handles error conditions and recovers from them. + +use crate::common::*; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::api::{Api, Patch, PatchParams}; +use sonarr_operator::crds::*; +use std::time::Duration; + +/// Test that operator recovers when Sonarr becomes available +#[tokio::test] +#[ignore = "requires E2E environment - run with: cargo test --test e2e -- --ignored"] +async fn test_recovery_after_sonarr_available() { + let mut ctx = TestContext::new() + .await + .expect("Failed to create test context"); + setup_e2e_namespace(&ctx.client) + .await + .expect("Failed to setup namespace"); + + // Ensure Sonarr is available + ctx.sonarr + .wait_for_ready(Duration::from_secs(60)) + .await + .expect("Sonarr should be ready"); + + // Create a tag + let tag_name = unique_name("e2e-recovery"); + ctx.register_cleanup("SonarrTag", E2E_NAMESPACE, &tag_name); + + let tag = SonarrTag { + metadata: ObjectMeta { + name: Some(tag_name.clone()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrTagSpec { + label: tag_name.clone(), + sonarr_instance_ref: SonarrInstanceRef { + name: "sonarr".to_string(), + namespace: Some("default".to_string()), + }, + }, + status: None, + }; + + apply_resource(&ctx.client, &tag) + .await + .expect("Failed to create tag"); + + // Wait for ready + wait_for_ready::(&ctx.client, E2E_NAMESPACE, &tag_name, E2E_TIMEOUT) + .await + .expect("Tag never became ready"); + + // Verify in Sonarr + let sonarr_tag = ctx + .sonarr + .find_tag_by_label(&tag_name) + .await + .expect("Failed to query Sonarr") + .expect("Tag not found"); + + tracing::info!("✓ Tag created successfully with ID: {}", sonarr_tag.id); + ctx.cleanup().await; +} + +/// Test handling of invalid Sonarr instance reference +#[tokio::test] +#[ignore = "requires E2E environment - run with: cargo test --test e2e -- --ignored"] +async fn test_invalid_sonarr_instance_reference() { + let mut ctx = TestContext::new() + .await + .expect("Failed to create test context"); + setup_e2e_namespace(&ctx.client) + .await + .expect("Failed to setup namespace"); + + let tag_name = unique_name("e2e-invalid-instance"); + ctx.register_cleanup("SonarrTag", E2E_NAMESPACE, &tag_name); + + // Create tag with non-existent Sonarr instance + let tag = SonarrTag { + metadata: ObjectMeta { + name: Some(tag_name.clone()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrTagSpec { + label: tag_name.clone(), + sonarr_instance_ref: SonarrInstanceRef { + name: "nonexistent-sonarr".to_string(), + namespace: Some("default".to_string()), + }, + }, + status: None, + }; + + apply_resource(&ctx.client, &tag) + .await + .expect("Failed to create tag CR"); + + // Wait for operator to process + tokio::time::sleep(Duration::from_secs(15)).await; + + // Check status - should have error condition + let api: Api = Api::namespaced(ctx.client.clone(), E2E_NAMESPACE); + let tag_status = api.get(&tag_name).await.expect("Failed to get tag"); + + if let Some(status) = &tag_status.status { + let has_error = status + .conditions + .iter() + .any(|c| c.type_ == "Ready" && c.status == "False"); + + if has_error { + let error_msg = status + .conditions + .iter() + .find(|c| c.type_ == "Ready" && c.status == "False") + .map(|c| c.message.clone()); + tracing::info!( + "✓ Tag correctly shows error for invalid instance: {:?}", + error_msg + ); + } else { + tracing::warn!("Tag doesn't show error for invalid instance reference"); + } + } + + ctx.cleanup().await; +} + +/// Test that updating a resource triggers reconciliation +#[tokio::test] +#[ignore = "requires E2E environment - run with: cargo test --test e2e -- --ignored"] +async fn test_resource_update_triggers_reconciliation() { + let mut ctx = TestContext::new() + .await + .expect("Failed to create test context"); + setup_e2e_namespace(&ctx.client) + .await + .expect("Failed to setup namespace"); + + let tag_name = unique_name("e2e-update-reconcile"); + ctx.register_cleanup("SonarrTag", E2E_NAMESPACE, &tag_name); + + // Create tag + let tag = SonarrTag { + metadata: ObjectMeta { + name: Some(tag_name.clone()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrTagSpec { + label: format!("{}-v1", tag_name), + sonarr_instance_ref: SonarrInstanceRef { + name: "sonarr".to_string(), + namespace: Some("default".to_string()), + }, + }, + status: None, + }; + + apply_resource(&ctx.client, &tag) + .await + .expect("Failed to create tag"); + wait_for_ready::(&ctx.client, E2E_NAMESPACE, &tag_name, E2E_TIMEOUT) + .await + .expect("Tag v1 never became ready"); + + // Verify v1 in Sonarr + let v1_label = format!("{}-v1", tag_name); + ctx.sonarr + .find_tag_by_label(&v1_label) + .await + .expect("Failed to query") + .expect("Tag v1 not found in Sonarr"); + tracing::info!("✓ Tag v1 created in Sonarr"); + + // Update to v2 + let api: Api = Api::namespaced(ctx.client.clone(), E2E_NAMESPACE); + let v2_label = format!("{}-v2", tag_name); + let patch = serde_json::json!({ + "apiVersion": "devopsarr.io/v1alpha1", + "kind": "SonarrTag", + "metadata": { + "name": tag_name, + "namespace": E2E_NAMESPACE + }, + "spec": { + "label": v2_label, + "sonarrInstanceRef": { + "name": "sonarr", + "namespace": "default" + } + } + }); + + api.patch( + &tag_name, + &PatchParams::apply("sonarr-e2e-test").force(), + &Patch::Apply(&patch), + ) + .await + .expect("Failed to update tag"); + + // Wait for update to propagate + tokio::time::sleep(Duration::from_secs(5)).await; + + // Verify v2 in Sonarr (v1 label should no longer exist) + let v2_in_sonarr = ctx + .sonarr + .find_tag_by_label(&v2_label) + .await + .expect("Failed to query"); + + assert!(v2_in_sonarr.is_some(), "Tag v2 should exist in Sonarr"); + + let v1_in_sonarr = ctx + .sonarr + .find_tag_by_label(&v1_label) + .await + .expect("Failed to query"); + + assert!( + v1_in_sonarr.is_none(), + "Tag v1 label should not exist after update" + ); + + tracing::info!("✓ Tag update correctly reconciled - v1 -> v2"); + ctx.cleanup().await; +} + +/// Test rapid updates don't cause issues +#[tokio::test] +#[ignore = "requires E2E environment - run with: cargo test --test e2e -- --ignored"] +async fn test_rapid_updates_handling() { + let mut ctx = TestContext::new() + .await + .expect("Failed to create test context"); + setup_e2e_namespace(&ctx.client) + .await + .expect("Failed to setup namespace"); + + let tag_name = unique_name("e2e-rapid"); + ctx.register_cleanup("SonarrTag", E2E_NAMESPACE, &tag_name); + + let api: Api = Api::namespaced(ctx.client.clone(), E2E_NAMESPACE); + + // Create initial tag + let tag = SonarrTag { + metadata: ObjectMeta { + name: Some(tag_name.clone()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrTagSpec { + label: format!("{}-initial", tag_name), + sonarr_instance_ref: SonarrInstanceRef { + name: "sonarr".to_string(), + namespace: Some("default".to_string()), + }, + }, + status: None, + }; + + apply_resource(&ctx.client, &tag) + .await + .expect("Failed to create tag"); + + // Rapid fire updates + for i in 1..=5 { + let patch = serde_json::json!({ + "apiVersion": "devopsarr.io/v1alpha1", + "kind": "SonarrTag", + "metadata": { + "name": tag_name, + "namespace": E2E_NAMESPACE + }, + "spec": { + "label": format!("{}-update-{}", tag_name, i), + "sonarrInstanceRef": { + "name": "sonarr", + "namespace": "default" + } + } + }); + + api.patch( + &tag_name, + &PatchParams::apply("sonarr-e2e-test").force(), + &Patch::Apply(&patch), + ) + .await + .expect("Failed to update tag"); + + // Small delay between updates + tokio::time::sleep(Duration::from_millis(100)).await; + } + + // Wait for final reconciliation + tokio::time::sleep(Duration::from_secs(10)).await; + + // Verify final state + let final_label = format!("{}-update-5", tag_name); + let sonarr_tag = ctx + .sonarr + .find_tag_by_label(&final_label) + .await + .expect("Failed to query"); + + assert!(sonarr_tag.is_some(), "Final tag state should be in Sonarr"); + tracing::info!("✓ Rapid updates handled correctly"); + + ctx.cleanup().await; +} diff --git a/tests/e2e/scenarios/indexer_config_lifecycle.rs b/tests/e2e/scenarios/indexer_config_lifecycle.rs new file mode 100644 index 0000000..a0313ed --- /dev/null +++ b/tests/e2e/scenarios/indexer_config_lifecycle.rs @@ -0,0 +1,225 @@ +//! E2E tests for IndexerConfig resource +//! +//! Tests global indexer configuration management via the SonarrIndexerConfig CRD. + +use crate::common::*; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::api::{Api, Patch, PatchParams}; +use sonarr_operator::crds::*; +use std::time::Duration; + +/// Test IndexerConfig updates global indexer settings in Sonarr +#[tokio::test] +#[ignore = "requires E2E environment - run with: cargo test --test e2e -- --ignored"] +async fn test_indexer_config_update() { + let mut ctx = TestContext::new() + .await + .expect("Failed to create test context"); + setup_e2e_namespace(&ctx.client) + .await + .expect("Failed to setup namespace"); + + // Get current config from Sonarr for comparison + let original_config = ctx + .sonarr + .get_indexer_config() + .await + .expect("Failed to get current indexer config"); + tracing::info!( + "Original config: rss_sync_interval = {}, minimum_age = {}", + original_config.rss_sync_interval, + original_config.minimum_age + ); + + // Create IndexerConfig CR + let config_name = unique_name("e2e-ic"); + ctx.register_cleanup("SonarrIndexerConfig", E2E_NAMESPACE, &config_name); + + let new_rss_interval = if original_config.rss_sync_interval == 15 { + 25 + } else { + 15 + }; + let new_minimum_age = if original_config.minimum_age == 0 { + 5 + } else { + 0 + }; + + let config = SonarrIndexerConfig { + metadata: ObjectMeta { + name: Some(config_name.clone()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrIndexerConfigSpec { + rss_sync_interval: Some(new_rss_interval), + minimum_age: Some(new_minimum_age), + retention: Some(0), + maximum_size: Some(0), + sonarr_instance_ref: SonarrInstanceRef { + name: "sonarr".to_string(), + namespace: Some("default".to_string()), + }, + }, + status: None, + }; + + apply_resource(&ctx.client, &config) + .await + .expect("Failed to create indexer config"); + + // Wait for Ready + wait_for_ready::(&ctx.client, E2E_NAMESPACE, &config_name, E2E_TIMEOUT) + .await + .expect("IndexerConfig never became ready"); + + // Give Sonarr time to apply the change + tokio::time::sleep(Duration::from_secs(3)).await; + + // Verify in Sonarr + let updated_config = ctx + .sonarr + .get_indexer_config() + .await + .expect("Failed to get updated indexer config"); + + assert_eq!( + updated_config.rss_sync_interval, new_rss_interval, + "RSS sync interval should be updated" + ); + assert_eq!( + updated_config.minimum_age, new_minimum_age, + "Minimum age should be updated" + ); + tracing::info!("✓ IndexerConfig verified in Sonarr"); + + // Restore original value + let api: Api = Api::namespaced(ctx.client.clone(), E2E_NAMESPACE); + let restore_patch = serde_json::json!({ + "apiVersion": "devopsarr.io/v1alpha1", + "kind": "SonarrIndexerConfig", + "metadata": { + "name": config_name, + "namespace": E2E_NAMESPACE + }, + "spec": { + "rssSyncInterval": original_config.rss_sync_interval, + "minimumAge": original_config.minimum_age, + "sonarrInstanceRef": { + "name": "sonarr", + "namespace": "default" + } + } + }); + + api.patch( + &config_name, + &PatchParams::apply("sonarr-e2e-test").force(), + &Patch::Apply(&restore_patch), + ) + .await + .expect("Failed to restore config"); + + tokio::time::sleep(Duration::from_secs(3)).await; + tracing::info!("✓ IndexerConfig restored to original values"); + + ctx.cleanup().await; +} + +/// Test IndexerConfig singleton constraint - only one per Sonarr instance +#[tokio::test] +#[ignore = "requires E2E environment - run with: cargo test --test e2e -- --ignored"] +async fn test_indexer_config_singleton_constraint() { + let mut ctx = TestContext::new() + .await + .expect("Failed to create test context"); + setup_e2e_namespace(&ctx.client) + .await + .expect("Failed to setup namespace"); + + // Create first IndexerConfig + let config1_name = unique_name("e2e-ic-1"); + ctx.register_cleanup("SonarrIndexerConfig", E2E_NAMESPACE, &config1_name); + + let config1 = SonarrIndexerConfig { + metadata: ObjectMeta { + name: Some(config1_name.clone()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrIndexerConfigSpec { + rss_sync_interval: Some(15), + sonarr_instance_ref: SonarrInstanceRef { + name: "sonarr".to_string(), + namespace: Some("default".to_string()), + }, + ..Default::default() + }, + status: None, + }; + + apply_resource(&ctx.client, &config1) + .await + .expect("Failed to create first indexer config"); + + wait_for_ready::(&ctx.client, E2E_NAMESPACE, &config1_name, E2E_TIMEOUT) + .await + .expect("First indexer config never became ready"); + + tracing::info!("First IndexerConfig created successfully"); + + // Try to create second config for same instance + let config2_name = unique_name("e2e-ic-2"); + ctx.register_cleanup("SonarrIndexerConfig", E2E_NAMESPACE, &config2_name); + + let config2 = SonarrIndexerConfig { + metadata: ObjectMeta { + name: Some(config2_name.clone()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrIndexerConfigSpec { + rss_sync_interval: Some(25), + sonarr_instance_ref: SonarrInstanceRef { + name: "sonarr".to_string(), + namespace: Some("default".to_string()), + }, + ..Default::default() + }, + status: None, + }; + + apply_resource(&ctx.client, &config2) + .await + .expect("Failed to create second indexer config CR"); + + // Wait for operator to process + tokio::time::sleep(Duration::from_secs(10)).await; + + // Check that second config has a conflict/error condition + let api: Api = Api::namespaced(ctx.client.clone(), E2E_NAMESPACE); + let config2_status = api + .get(&config2_name) + .await + .expect("Failed to get second config"); + + if let Some(status) = &config2_status.status { + let has_conflict = status + .conditions + .iter() + .any(|c| (c.type_ == "Ready" && c.status == "False") || c.type_ == "Conflict"); + + if has_conflict { + tracing::info!( + "✓ Singleton constraint enforced - second IndexerConfig has error/conflict condition" + ); + } else { + tracing::warn!( + "Second config doesn't show conflict - singleton may not be enforced at controller level" + ); + } + } + + ctx.cleanup().await; +} diff --git a/tests/e2e/scenarios/mod.rs b/tests/e2e/scenarios/mod.rs new file mode 100644 index 0000000..4096c45 --- /dev/null +++ b/tests/e2e/scenarios/mod.rs @@ -0,0 +1,13 @@ +//! E2E test scenarios +//! +//! Each module tests a specific resource type or cross-resource dependency. + +pub mod config_resources; +pub mod cross_resource_dependencies; +pub mod custom_format_lifecycle; +pub mod download_client_config_lifecycle; +pub mod error_recovery; +pub mod indexer_config_lifecycle; +pub mod quality_profile_lifecycle; +pub mod root_folder_lifecycle; +pub mod tag_lifecycle; diff --git a/tests/e2e/scenarios/quality_profile_lifecycle.rs b/tests/e2e/scenarios/quality_profile_lifecycle.rs new file mode 100644 index 0000000..c4b2879 --- /dev/null +++ b/tests/e2e/scenarios/quality_profile_lifecycle.rs @@ -0,0 +1,169 @@ +//! E2E tests for QualityProfile lifecycle + +use crate::common::*; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::api::{Api, DeleteParams, Patch, PatchParams}; +use sonarr_operator::crds::quality_profile::{Quality, QualityGroup}; +use sonarr_operator::crds::{SonarrInstanceRef, SonarrQualityProfile, SonarrQualityProfileSpec}; +use std::time::Duration; + +/// Test the full lifecycle of a QualityProfile +#[tokio::test] +#[ignore = "requires E2E environment - run with: cargo test --test e2e -- --ignored"] +async fn test_quality_profile_full_lifecycle() { + let mut ctx = TestContext::new() + .await + .expect("Failed to create test context"); + setup_e2e_namespace(&ctx.client) + .await + .expect("Failed to setup namespace"); + + let qp_name = unique_name("e2e-qp"); + let profile_name = format!("E2E Test Profile {}", qp_name); + + ctx.register_cleanup("SonarrQualityProfile", E2E_NAMESPACE, &qp_name); + + // Create the quality profile CR with minimal required fields + tracing::info!("Creating SonarrQualityProfile: {}", qp_name); + let quality_profile = SonarrQualityProfile { + metadata: ObjectMeta { + name: Some(qp_name.clone()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrQualityProfileSpec { + name: profile_name.clone(), + upgrade_allowed: true, + cutoff: 7, // Bluray-1080p + quality_groups: vec![QualityGroup { + id: None, + name: Some("HD".to_string()), + qualities: vec![Quality { + id: Some(7), + name: Some("Bluray-1080p".to_string()), + source: None, + resolution: None, + }], + }], + sonarr_instance_ref: SonarrInstanceRef { + name: "sonarr".to_string(), + namespace: Some("default".to_string()), + }, + cutoff_format_score: None, + min_format_score: None, + min_upgrade_format_score: Some(1), + format_items: vec![], + }, + status: None, + }; + + apply_resource(&ctx.client, &quality_profile) + .await + .expect("Failed to create quality profile CR"); + + // Wait for Ready condition + tracing::info!("Waiting for quality profile to be ready..."); + let ready_qp = + wait_for_ready::(&ctx.client, E2E_NAMESPACE, &qp_name, E2E_TIMEOUT) + .await + .expect("Quality profile never became ready"); + + let qp_id = ready_qp + .status + .as_ref() + .and_then(|s| s.id) + .expect("Quality profile should have an ID"); + tracing::info!("Quality profile created with ID: {}", qp_id); + + // Verify in Sonarr API + tracing::info!("Verifying quality profile exists in Sonarr..."); + let sonarr_qp = ctx + .sonarr + .find_quality_profile_by_name(&profile_name) + .await + .expect("Failed to query Sonarr API") + .expect("Quality profile not found in Sonarr"); + + assert_eq!(sonarr_qp.id, qp_id, "Quality profile ID mismatch"); + assert_eq!( + sonarr_qp.name, profile_name, + "Quality profile name mismatch" + ); + assert!(sonarr_qp.upgrade_allowed, "Upgrade should be allowed"); + tracing::info!("✓ Quality profile verified in Sonarr"); + + // Update the quality profile + let updated_profile_name = format!("{} Updated", profile_name); + tracing::info!("Updating quality profile name to: {}", updated_profile_name); + + let api: Api = Api::namespaced(ctx.client.clone(), E2E_NAMESPACE); + let patch = serde_json::json!({ + "apiVersion": "devopsarr.io/v1alpha1", + "kind": "SonarrQualityProfile", + "metadata": { + "name": qp_name, + "namespace": E2E_NAMESPACE + }, + "spec": { + "name": updated_profile_name, + "upgradeAllowed": false, + "cutoff": 7, + "qualityGroups": [{ + "name": "HD", + "qualities": [{"id": 7, "name": "Bluray-1080p"}] + }], + "sonarrInstanceRef": { + "name": "sonarr", + "namespace": "default" + } + } + }); + + api.patch( + &qp_name, + &PatchParams::apply("sonarr-e2e-test").force(), + &Patch::Apply(&patch), + ) + .await + .expect("Failed to update quality profile"); + + tokio::time::sleep(Duration::from_secs(5)).await; + + // Verify update in Sonarr + let updated_sonarr_qp = ctx + .sonarr + .find_quality_profile_by_name(&updated_profile_name) + .await + .expect("Failed to query Sonarr API") + .expect("Updated quality profile not found in Sonarr"); + + assert_eq!(updated_sonarr_qp.id, qp_id, "ID should not change"); + assert!( + !updated_sonarr_qp.upgrade_allowed, + "Upgrade should be disabled" + ); + tracing::info!("✓ Quality profile update verified in Sonarr"); + + // Delete and verify cleanup + api.delete(&qp_name, &DeleteParams::default()) + .await + .expect("Failed to delete quality profile CR"); + + wait_for_deletion::(&ctx.client, E2E_NAMESPACE, &qp_name, QUICK_TIMEOUT) + .await + .expect("Quality profile CR was not deleted"); + + tokio::time::sleep(Duration::from_secs(3)).await; + + let deleted = ctx + .sonarr + .find_quality_profile_by_name(&updated_profile_name) + .await; + assert!( + matches!(deleted, Ok(None)), + "Quality profile should be deleted from Sonarr" + ); + tracing::info!("✓ Quality profile deleted from Sonarr"); + + ctx.cleanup().await; +} diff --git a/tests/e2e/scenarios/root_folder_lifecycle.rs b/tests/e2e/scenarios/root_folder_lifecycle.rs new file mode 100644 index 0000000..fbd285d --- /dev/null +++ b/tests/e2e/scenarios/root_folder_lifecycle.rs @@ -0,0 +1,99 @@ +//! E2E tests for RootFolder lifecycle + +use crate::common::*; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::api::{Api, DeleteParams}; +use sonarr_operator::crds::{SonarrInstanceRef, SonarrRootFolder, SonarrRootFolderSpec}; +use std::time::Duration; + +/// Test the full lifecycle of a RootFolder +#[tokio::test] +#[ignore = "requires E2E environment - run with: cargo test --test e2e -- --ignored"] +async fn test_root_folder_full_lifecycle() { + let mut ctx = TestContext::new() + .await + .expect("Failed to create test context"); + setup_e2e_namespace(&ctx.client) + .await + .expect("Failed to setup namespace"); + + let rf_name = unique_name("e2e-rf"); + // Use /tmp which exists in the Sonarr container (Sonarr validates the path exists) + let rf_path = "/tmp".to_string(); + + ctx.register_cleanup("SonarrRootFolder", E2E_NAMESPACE, &rf_name); + + // Create the root folder CR + tracing::info!( + "Creating SonarrRootFolder: {} with path: {}", + rf_name, + rf_path + ); + let root_folder = SonarrRootFolder { + metadata: ObjectMeta { + name: Some(rf_name.clone()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrRootFolderSpec { + path: rf_path.clone(), + sonarr_instance_ref: SonarrInstanceRef { + name: "sonarr".to_string(), + namespace: Some("default".to_string()), + }, + }, + status: None, + }; + + apply_resource(&ctx.client, &root_folder) + .await + .expect("Failed to create root folder CR"); + + // Wait for Ready condition + tracing::info!("Waiting for root folder to be ready..."); + let ready_rf = + wait_for_ready::(&ctx.client, E2E_NAMESPACE, &rf_name, E2E_TIMEOUT) + .await + .expect("Root folder never became ready"); + + let rf_id = ready_rf + .status + .as_ref() + .and_then(|s| s.id) + .expect("Root folder should have an ID"); + tracing::info!("Root folder created with ID: {}", rf_id); + + // Verify in Sonarr API + tracing::info!("Verifying root folder exists in Sonarr..."); + let sonarr_rf = ctx + .sonarr + .find_root_folder_by_path(&rf_path) + .await + .expect("Failed to query Sonarr API") + .expect("Root folder not found in Sonarr"); + + assert_eq!(sonarr_rf.id, rf_id, "Root folder ID mismatch"); + assert_eq!(sonarr_rf.path, rf_path, "Root folder path mismatch"); + tracing::info!("✓ Root folder verified in Sonarr"); + + // Delete and verify cleanup + let api: Api = Api::namespaced(ctx.client.clone(), E2E_NAMESPACE); + api.delete(&rf_name, &DeleteParams::default()) + .await + .expect("Failed to delete root folder CR"); + + wait_for_deletion::(&ctx.client, E2E_NAMESPACE, &rf_name, QUICK_TIMEOUT) + .await + .expect("Root folder CR was not deleted"); + + tokio::time::sleep(Duration::from_secs(3)).await; + + let deleted = ctx.sonarr.find_root_folder_by_path(&rf_path).await; + assert!( + matches!(deleted, Ok(None)), + "Root folder should be deleted from Sonarr" + ); + tracing::info!("✓ Root folder deleted from Sonarr"); + + ctx.cleanup().await; +} diff --git a/tests/e2e/scenarios/tag_lifecycle.rs b/tests/e2e/scenarios/tag_lifecycle.rs new file mode 100644 index 0000000..82bafd4 --- /dev/null +++ b/tests/e2e/scenarios/tag_lifecycle.rs @@ -0,0 +1,222 @@ +//! E2E tests for Tag lifecycle +//! +//! Tests the full lifecycle of creating, updating, and deleting tags +//! with verification against the Sonarr API. + +use crate::common::*; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::api::{Api, DeleteParams, Patch, PatchParams}; +use sonarr_operator::crds::{SonarrInstanceRef, SonarrTag, SonarrTagSpec}; +use std::time::Duration; + +/// Test the full lifecycle of a Tag: create -> verify in Sonarr -> update -> verify -> delete -> verify deleted +#[tokio::test] +#[ignore = "requires E2E environment - run with: cargo test --test e2e -- --ignored"] +async fn test_tag_full_lifecycle() { + let mut ctx = TestContext::new() + .await + .expect("Failed to create test context"); + + // Setup namespace + setup_e2e_namespace(&ctx.client) + .await + .expect("Failed to setup namespace"); + + let tag_name = unique_name("e2e-tag"); + let tag_label = format!("e2e-test-{}", &tag_name); + + // Register for cleanup + ctx.register_cleanup("SonarrTag", E2E_NAMESPACE, &tag_name); + + // Step 1: Create the tag CR + tracing::info!("Creating SonarrTag: {}", tag_name); + let tag = SonarrTag { + metadata: ObjectMeta { + name: Some(tag_name.clone()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrTagSpec { + label: tag_label.clone(), + sonarr_instance_ref: SonarrInstanceRef { + name: "sonarr".to_string(), + namespace: Some("default".to_string()), + }, + }, + status: None, + }; + + apply_resource(&ctx.client, &tag) + .await + .expect("Failed to create tag CR"); + + // Step 2: Wait for Ready condition + tracing::info!("Waiting for tag to be ready..."); + let ready_tag = wait_for_ready::(&ctx.client, E2E_NAMESPACE, &tag_name, E2E_TIMEOUT) + .await + .expect("Tag never became ready"); + + // Verify the tag has an ID assigned + let tag_id = ready_tag + .status + .as_ref() + .and_then(|s| s.id) + .expect("Tag should have an ID after reconciliation"); + tracing::info!("Tag created with ID: {}", tag_id); + + // Step 3: Verify in Sonarr API + tracing::info!("Verifying tag exists in Sonarr..."); + let sonarr_tag = ctx + .sonarr + .find_tag_by_label(&tag_label) + .await + .expect("Failed to query Sonarr API") + .expect("Tag not found in Sonarr"); + + assert_eq!(sonarr_tag.id, tag_id, "Tag ID mismatch"); + assert_eq!(sonarr_tag.label, tag_label, "Tag label mismatch"); + tracing::info!("✓ Tag verified in Sonarr"); + + // Step 4: Update the tag + let updated_label = format!("{}-updated", tag_label); + tracing::info!("Updating tag label to: {}", updated_label); + + let api: Api = Api::namespaced(ctx.client.clone(), E2E_NAMESPACE); + let patch = serde_json::json!({ + "apiVersion": "devopsarr.io/v1alpha1", + "kind": "SonarrTag", + "metadata": { + "name": tag_name, + "namespace": E2E_NAMESPACE + }, + "spec": { + "label": updated_label, + "sonarrInstanceRef": { + "name": "sonarr", + "namespace": "default" + } + } + }); + + api.patch( + &tag_name, + &PatchParams::apply("sonarr-e2e-test").force(), + &Patch::Apply(&patch), + ) + .await + .expect("Failed to update tag"); + + // Wait for update to propagate + tokio::time::sleep(Duration::from_secs(5)).await; + + // Step 5: Verify update in Sonarr + tracing::info!("Verifying tag update in Sonarr..."); + let updated_sonarr_tag = ctx + .sonarr + .find_tag_by_label(&updated_label) + .await + .expect("Failed to query Sonarr API") + .expect("Updated tag not found in Sonarr"); + + assert_eq!( + updated_sonarr_tag.id, tag_id, + "Tag ID should not change on update" + ); + assert_eq!( + updated_sonarr_tag.label, updated_label, + "Tag label should be updated" + ); + tracing::info!("✓ Tag update verified in Sonarr"); + + // Step 6: Delete the tag CR + tracing::info!("Deleting SonarrTag: {}", tag_name); + api.delete(&tag_name, &DeleteParams::default()) + .await + .expect("Failed to delete tag CR"); + + // Step 7: Wait for deletion + wait_for_deletion::(&ctx.client, E2E_NAMESPACE, &tag_name, QUICK_TIMEOUT) + .await + .expect("Tag CR was not deleted"); + + // Step 8: Verify deleted from Sonarr (give operator time to process finalizer) + tokio::time::sleep(Duration::from_secs(3)).await; + + tracing::info!("Verifying tag deleted from Sonarr..."); + let deleted_tag = ctx.sonarr.find_tag_by_label(&updated_label).await; + + match deleted_tag { + Ok(None) => tracing::info!("✓ Tag successfully deleted from Sonarr"), + Ok(Some(_)) => panic!("Tag still exists in Sonarr after CR deletion"), + Err(e) => tracing::warn!("Could not verify deletion: {:?}", e), + } + + // Cleanup is handled by TestContext + ctx.cleanup().await; +} + +/// Test creating multiple tags +#[tokio::test] +#[ignore = "requires E2E environment - run with: cargo test --test e2e -- --ignored"] +async fn test_multiple_tags() { + let mut ctx = TestContext::new() + .await + .expect("Failed to create test context"); + setup_e2e_namespace(&ctx.client) + .await + .expect("Failed to setup namespace"); + + let tag_names: Vec = (1..=3) + .map(|i| unique_name(&format!("e2e-multi-{}", i))) + .collect(); + + // Create multiple tags + for tag_name in &tag_names { + ctx.register_cleanup("SonarrTag", E2E_NAMESPACE, tag_name); + + let tag = SonarrTag { + metadata: ObjectMeta { + name: Some(tag_name.clone()), + namespace: Some(E2E_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrTagSpec { + label: tag_name.clone(), + sonarr_instance_ref: SonarrInstanceRef { + name: "sonarr".to_string(), + namespace: Some("default".to_string()), + }, + }, + status: None, + }; + + apply_resource(&ctx.client, &tag) + .await + .expect("Failed to create tag"); + } + + // Wait for all to be ready + for tag_name in &tag_names { + wait_for_ready::(&ctx.client, E2E_NAMESPACE, tag_name, E2E_TIMEOUT) + .await + .expect("Tag never became ready"); + } + + // Verify all exist in Sonarr + for tag_name in &tag_names { + let sonarr_tag = ctx + .sonarr + .find_tag_by_label(tag_name) + .await + .expect("Failed to query Sonarr") + .expect("Tag not found in Sonarr"); + + tracing::info!( + "✓ Tag {} created in Sonarr with ID {}", + tag_name, + sonarr_tag.id + ); + } + + ctx.cleanup().await; +} diff --git a/tests/e2e/sonarr_client.rs b/tests/e2e/sonarr_client.rs new file mode 100644 index 0000000..583fbe1 --- /dev/null +++ b/tests/e2e/sonarr_client.rs @@ -0,0 +1,534 @@ +//! Sonarr API client for E2E test verification +//! +//! This client directly queries the Sonarr API to verify that resources +//! created by the operator actually exist in Sonarr. + +use anyhow::{Result, anyhow}; +use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderValue}; +use serde::{Deserialize, Serialize}; +use std::time::Duration; + +/// Client for querying Sonarr API directly during E2E tests +pub struct SonarrTestClient { + client: reqwest::Client, + base_url: String, +} + +impl SonarrTestClient { + /// Create a new Sonarr test client + pub fn new(base_url: &str, api_key: &str) -> Result { + let mut headers = HeaderMap::new(); + headers.insert("X-Api-Key", HeaderValue::from_str(api_key)?); + headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + + let client = reqwest::Client::builder() + .default_headers(headers) + .timeout(Duration::from_secs(30)) + .build()?; + + Ok(Self { + client, + base_url: base_url.trim_end_matches('/').to_string(), + }) + } + + /// Check if Sonarr is healthy + pub async fn health_check(&self) -> Result { + let resp = self + .client + .get(format!("{}/api/v3/system/status", self.base_url)) + .send() + .await?; + Ok(resp.status().is_success()) + } + + /// Wait for Sonarr to be ready + pub async fn wait_for_ready(&self, timeout: Duration) -> Result<()> { + let start = std::time::Instant::now(); + while start.elapsed() < timeout { + if self.health_check().await.unwrap_or(false) { + return Ok(()); + } + tokio::time::sleep(Duration::from_secs(2)).await; + } + Err(anyhow!("Timeout waiting for Sonarr to be ready")) + } + + // ========== Tags ========== + + /// Get all tags from Sonarr + pub async fn get_tags(&self) -> Result> { + let resp = self + .client + .get(format!("{}/api/v3/tag", self.base_url)) + .send() + .await? + .error_for_status()?; + Ok(resp.json().await?) + } + + /// Find a tag by label + pub async fn find_tag_by_label(&self, label: &str) -> Result> { + let tags = self.get_tags().await?; + Ok(tags.into_iter().find(|t| t.label == label)) + } + + /// Delete a tag by ID + pub async fn delete_tag(&self, id: i32) -> Result<()> { + self.client + .delete(format!("{}/api/v3/tag/{}", self.base_url, id)) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + // ========== Root Folders ========== + + /// Get all root folders from Sonarr + pub async fn get_root_folders(&self) -> Result> { + let resp = self + .client + .get(format!("{}/api/v3/rootfolder", self.base_url)) + .send() + .await? + .error_for_status()?; + Ok(resp.json().await?) + } + + /// Find a root folder by path + pub async fn find_root_folder_by_path(&self, path: &str) -> Result> { + let folders = self.get_root_folders().await?; + Ok(folders.into_iter().find(|f| f.path == path)) + } + + /// Delete a root folder by ID + pub async fn delete_root_folder(&self, id: i32) -> Result<()> { + self.client + .delete(format!("{}/api/v3/rootfolder/{}", self.base_url, id)) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + // ========== Quality Profiles ========== + + /// Get all quality profiles from Sonarr + pub async fn get_quality_profiles(&self) -> Result> { + let resp = self + .client + .get(format!("{}/api/v3/qualityprofile", self.base_url)) + .send() + .await? + .error_for_status()?; + Ok(resp.json().await?) + } + + /// Find a quality profile by name + pub async fn find_quality_profile_by_name(&self, name: &str) -> Result> { + let profiles = self.get_quality_profiles().await?; + Ok(profiles.into_iter().find(|p| p.name == name)) + } + + /// Delete a quality profile by ID + pub async fn delete_quality_profile(&self, id: i32) -> Result<()> { + self.client + .delete(format!("{}/api/v3/qualityprofile/{}", self.base_url, id)) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + // ========== Custom Formats ========== + + /// Get all custom formats from Sonarr + pub async fn get_custom_formats(&self) -> Result> { + let resp = self + .client + .get(format!("{}/api/v3/customformat", self.base_url)) + .send() + .await? + .error_for_status()?; + Ok(resp.json().await?) + } + + /// Find a custom format by name + pub async fn find_custom_format_by_name(&self, name: &str) -> Result> { + let formats = self.get_custom_formats().await?; + Ok(formats.into_iter().find(|f| f.name == name)) + } + + /// Delete a custom format by ID + pub async fn delete_custom_format(&self, id: i32) -> Result<()> { + self.client + .delete(format!("{}/api/v3/customformat/{}", self.base_url, id)) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + // ========== Notifications ========== + + /// Get all notifications from Sonarr + pub async fn get_notifications(&self) -> Result> { + let resp = self + .client + .get(format!("{}/api/v3/notification", self.base_url)) + .send() + .await? + .error_for_status()?; + Ok(resp.json().await?) + } + + /// Find a notification by name + pub async fn find_notification_by_name(&self, name: &str) -> Result> { + let notifications = self.get_notifications().await?; + Ok(notifications.into_iter().find(|n| n.name == name)) + } + + /// Delete a notification by ID + pub async fn delete_notification(&self, id: i32) -> Result<()> { + self.client + .delete(format!("{}/api/v3/notification/{}", self.base_url, id)) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + // ========== Download Clients ========== + + /// Get all download clients from Sonarr + pub async fn get_download_clients(&self) -> Result> { + let resp = self + .client + .get(format!("{}/api/v3/downloadclient", self.base_url)) + .send() + .await? + .error_for_status()?; + Ok(resp.json().await?) + } + + /// Find a download client by name + pub async fn find_download_client_by_name(&self, name: &str) -> Result> { + let clients = self.get_download_clients().await?; + Ok(clients.into_iter().find(|c| c.name == name)) + } + + /// Delete a download client by ID + pub async fn delete_download_client(&self, id: i32) -> Result<()> { + self.client + .delete(format!("{}/api/v3/downloadclient/{}", self.base_url, id)) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + // ========== Indexers ========== + + /// Get all indexers from Sonarr + pub async fn get_indexers(&self) -> Result> { + let resp = self + .client + .get(format!("{}/api/v3/indexer", self.base_url)) + .send() + .await? + .error_for_status()?; + Ok(resp.json().await?) + } + + /// Find an indexer by name + pub async fn find_indexer_by_name(&self, name: &str) -> Result> { + let indexers = self.get_indexers().await?; + Ok(indexers.into_iter().find(|i| i.name == name)) + } + + /// Delete an indexer by ID + pub async fn delete_indexer(&self, id: i32) -> Result<()> { + self.client + .delete(format!("{}/api/v3/indexer/{}", self.base_url, id)) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + // ========== Auto Tags ========== + + /// Get all auto tags from Sonarr + pub async fn get_auto_tags(&self) -> Result> { + let resp = self + .client + .get(format!("{}/api/v3/autotagging", self.base_url)) + .send() + .await? + .error_for_status()?; + Ok(resp.json().await?) + } + + /// Find an auto tag by name + pub async fn find_auto_tag_by_name(&self, name: &str) -> Result> { + let auto_tags = self.get_auto_tags().await?; + Ok(auto_tags.into_iter().find(|a| a.name == name)) + } + + /// Delete an auto tag by ID + pub async fn delete_auto_tag(&self, id: i32) -> Result<()> { + self.client + .delete(format!("{}/api/v3/autotagging/{}", self.base_url, id)) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + // ========== Media Management Config ========== + + /// Get media management config + pub async fn get_media_management_config(&self) -> Result { + let resp = self + .client + .get(format!("{}/api/v3/config/mediamanagement", self.base_url)) + .send() + .await? + .error_for_status()?; + Ok(resp.json().await?) + } + + // ========== Naming Config ========== + + /// Get naming config + pub async fn get_naming_config(&self) -> Result { + let resp = self + .client + .get(format!("{}/api/v3/config/naming", self.base_url)) + .send() + .await? + .error_for_status()?; + Ok(resp.json().await?) + } + + // ========== Delay Profiles ========== + + /// Get all delay profiles from Sonarr + pub async fn get_delay_profiles(&self) -> Result> { + let resp = self + .client + .get(format!("{}/api/v3/delayprofile", self.base_url)) + .send() + .await? + .error_for_status()?; + Ok(resp.json().await?) + } + + /// Delete a delay profile by ID + pub async fn delete_delay_profile(&self, id: i32) -> Result<()> { + self.client + .delete(format!("{}/api/v3/delayprofile/{}", self.base_url, id)) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + // ========== Indexer Config ========== + + /// Get global indexer config from Sonarr + pub async fn get_indexer_config(&self) -> Result { + let resp = self + .client + .get(format!("{}/api/v3/config/indexer", self.base_url)) + .send() + .await? + .error_for_status()?; + Ok(resp.json().await?) + } + + // ========== Download Client Config ========== + + /// Get global download client config from Sonarr + pub async fn get_download_client_config(&self) -> Result { + let resp = self + .client + .get(format!("{}/api/v3/config/downloadclient", self.base_url)) + .send() + .await? + .error_for_status()?; + Ok(resp.json().await?) + } +} + +// ========== API Response Types ========== + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Tag { + pub id: i32, + pub label: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RootFolder { + pub id: i32, + pub path: String, + #[serde(default)] + pub accessible: bool, + #[serde(default)] + pub free_space: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QualityProfile { + pub id: i32, + pub name: String, + #[serde(default)] + pub upgrade_allowed: bool, + #[serde(default)] + pub cutoff: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CustomFormat { + pub id: i32, + pub name: String, + #[serde(default)] + pub include_custom_format_when_renaming: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Notification { + pub id: i32, + pub name: String, + pub implementation: String, + #[serde(default)] + pub on_grab: bool, + #[serde(default)] + pub on_download: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DownloadClient { + pub id: i32, + pub name: String, + pub implementation: String, + #[serde(default)] + pub enable: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Indexer { + pub id: i32, + pub name: String, + pub implementation: String, + #[serde(default)] + pub enable_rss: bool, + #[serde(default)] + pub enable_automatic_search: bool, + #[serde(default)] + pub enable_interactive_search: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AutoTag { + pub id: i32, + pub name: String, + #[serde(default)] + pub remove_tags_automatically: bool, + #[serde(default)] + pub tags: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MediaManagementConfig { + pub id: i32, + #[serde(default)] + pub auto_unmonitor_previously_downloaded_episodes: bool, + #[serde(default)] + pub recycle_bin: String, + #[serde(default)] + pub recycle_bin_cleanup_days: i32, + #[serde(default)] + pub create_empty_series_folders: bool, + #[serde(default)] + pub delete_empty_folders: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NamingConfig { + pub id: i32, + #[serde(default)] + pub rename_episodes: bool, + #[serde(default)] + pub replace_illegal_characters: bool, + #[serde(default)] + pub standard_episode_format: String, + #[serde(default)] + pub daily_episode_format: String, + #[serde(default)] + pub anime_episode_format: String, + #[serde(default)] + pub series_folder_format: String, + #[serde(default)] + pub season_folder_format: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DelayProfile { + pub id: i32, + #[serde(default)] + pub enable_usenet: bool, + #[serde(default)] + pub enable_torrent: bool, + #[serde(default)] + pub preferred_protocol: String, + #[serde(default)] + pub usenet_delay: i32, + #[serde(default)] + pub torrent_delay: i32, + #[serde(default)] + pub order: i32, + #[serde(default)] + pub tags: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IndexerConfig { + pub id: i32, + #[serde(default)] + pub minimum_age: i32, + #[serde(default)] + pub retention: i32, + #[serde(default)] + pub maximum_size: i32, + #[serde(default)] + pub rss_sync_interval: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DownloadClientConfig { + pub id: i32, + #[serde(default)] + pub download_client_working_folders: String, + #[serde(default)] + pub enable_completed_download_handling: bool, + #[serde(default)] + pub auto_redownload_failed: bool, + #[serde(default)] + pub auto_redownload_failed_from_interactive_search: bool, +} diff --git a/tests/integration/auto_tag_crd.rs b/tests/integration/auto_tag_crd.rs new file mode 100644 index 0000000..0fc1386 --- /dev/null +++ b/tests/integration/auto_tag_crd.rs @@ -0,0 +1,294 @@ +//! Integration tests for the SonarrAutoTag CRD + +use crate::common::*; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::api::{Api, DeleteParams, Patch, PatchParams}; +use sonarr_operator::crds::auto_tag::{ + AutoTagFields, AutoTagImplementation, AutoTagSpecification, SonarrAutoTagSpec, +}; +use sonarr_operator::crds::{SonarrAutoTag, SonarrInstanceRef}; + +/// Test that the SonarrAutoTag CRD is installed and established +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_sonarr_auto_tag_crd_is_established() { + let client = test_client().await; + + assert!( + is_crd_established(&client, "sonarrautotags.devopsarr.io").await, + "SonarrAutoTag CRD is not established - run 'make install' first" + ); +} + +/// Test creating a SonarrAutoTag resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_create_sonarr_auto_tag() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("auto-tag-test"); + let auto_tag = SonarrAutoTag { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrAutoTagSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + name: "Anime Auto Tag".to_string(), + remove_tags_automatically: true, + tags: vec![1, 2], + specifications: vec![ + AutoTagSpecification { + name: "Anime Genre".to_string(), + implementation: AutoTagImplementation::GenreSpecification, + negate: false, + required: true, + fields: AutoTagFields { + value: Some("Anime".to_string()), + min: None, + max: None, + }, + }, + AutoTagSpecification { + name: "Japanese Language".to_string(), + implementation: AutoTagImplementation::OriginalLanguageSpecification, + negate: false, + required: false, + fields: AutoTagFields { + value: Some("Japanese".to_string()), + min: None, + max: None, + }, + }, + ], + }, + status: None, + }; + + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + let result = api + .patch(&name, &patch_params, &Patch::Apply(&auto_tag)) + .await; + + assert!( + result.is_ok(), + "Failed to create SonarrAutoTag: {:?}", + result.err() + ); + + let retrieved = api.get(&name).await; + assert!( + retrieved.is_ok(), + "Failed to get SonarrAutoTag: {:?}", + retrieved.err() + ); + + let auto_tag = retrieved.unwrap(); + assert_eq!(auto_tag.spec.name, "Anime Auto Tag"); + assert!(auto_tag.spec.remove_tags_automatically); + assert_eq!(auto_tag.spec.tags.len(), 2); + assert_eq!(auto_tag.spec.specifications.len(), 2); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test updating a SonarrAutoTag resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_update_sonarr_auto_tag() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("auto-tag-update"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + // Create initial resource + let auto_tag = SonarrAutoTag { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrAutoTagSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + name: "Original Auto Tag".to_string(), + remove_tags_automatically: false, + tags: vec![1], + specifications: vec![], + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&auto_tag)) + .await + .expect("Failed to create SonarrAutoTag"); + + // Update the resource + let updated = SonarrAutoTag { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrAutoTagSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + name: "Updated Auto Tag".to_string(), + remove_tags_automatically: true, + tags: vec![1, 2, 3], + specifications: vec![AutoTagSpecification { + name: "Root Folder".to_string(), + implementation: AutoTagImplementation::RootFolderSpecification, + negate: false, + required: true, + fields: AutoTagFields { + value: Some("/tv/anime".to_string()), + min: None, + max: None, + }, + }], + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&updated)) + .await + .expect("Failed to update SonarrAutoTag"); + + let retrieved = api.get(&name).await.expect("Failed to get SonarrAutoTag"); + assert_eq!(retrieved.spec.name, "Updated Auto Tag"); + assert!(retrieved.spec.remove_tags_automatically); + assert_eq!(retrieved.spec.tags.len(), 3); + assert_eq!(retrieved.spec.specifications.len(), 1); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test deleting a SonarrAutoTag resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_delete_sonarr_auto_tag() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("auto-tag-delete"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + let auto_tag = SonarrAutoTag { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrAutoTagSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + name: "To Be Deleted".to_string(), + remove_tags_automatically: false, + tags: vec![], + specifications: vec![], + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&auto_tag)) + .await + .expect("Failed to create SonarrAutoTag"); + + let delete_result = api.delete(&name, &DeleteParams::default()).await; + assert!( + delete_result.is_ok(), + "Failed to delete SonarrAutoTag: {:?}", + delete_result.err() + ); + + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + let get_result = api.get(&name).await; + assert!( + get_result.is_err(), + "SonarrAutoTag should have been deleted" + ); +} + +/// Test creating auto tags with different specification types +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_create_auto_tags_with_different_specs() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + // Create an auto tag with year specification + let name = unique_name("auto-tag-year"); + let auto_tag = SonarrAutoTag { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrAutoTagSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + name: "Recent Shows".to_string(), + remove_tags_automatically: true, + tags: vec![1], + specifications: vec![AutoTagSpecification { + name: "Year Range".to_string(), + implementation: AutoTagImplementation::YearSpecification, + negate: false, + required: true, + fields: AutoTagFields { + value: None, + min: Some(2020), + max: Some(2030), + }, + }], + }, + status: None, + }; + + let result = api + .patch(&name, &patch_params, &Patch::Apply(&auto_tag)) + .await; + assert!( + result.is_ok(), + "Failed to create auto tag with year spec: {:?}", + result.err() + ); + + let retrieved = api.get(&name).await.expect("Failed to get auto tag"); + assert_eq!(retrieved.spec.specifications[0].fields.min, Some(2020)); + assert_eq!(retrieved.spec.specifications[0].fields.max, Some(2030)); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} diff --git a/tests/integration/common.rs b/tests/integration/common.rs new file mode 100644 index 0000000..405eecb --- /dev/null +++ b/tests/integration/common.rs @@ -0,0 +1,206 @@ +//! Common utilities for integration tests + +#![allow(dead_code)] + +use k8s_openapi::apiextensions_apiserver::pkg::apis::apiextensions::v1::CustomResourceDefinition; +use kube::{ + Client, Resource, + api::{Api, DeleteParams, ListParams, Patch, PatchParams}, +}; +use std::time::Duration; +use tokio::time::timeout; + +/// Default timeout for test operations +pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); + +/// Test namespace for integration tests +pub const TEST_NAMESPACE: &str = "sonarr-operator-test"; + +/// Create a test client from the current kubeconfig context +pub async fn test_client() -> Client { + Client::try_default() + .await + .expect("Failed to create Kubernetes client - is your kubeconfig configured?") +} + +/// Ensure the test namespace exists +pub async fn ensure_test_namespace(client: &Client) -> Result<(), kube::Error> { + use k8s_openapi::api::core::v1::Namespace; + use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; + + let namespaces: Api = Api::all(client.clone()); + + let ns = Namespace { + metadata: ObjectMeta { + name: Some(TEST_NAMESPACE.to_string()), + labels: Some( + [( + "app.kubernetes.io/managed-by".to_string(), + "sonarr-operator-test".to_string(), + )] + .into(), + ), + ..Default::default() + }, + ..Default::default() + }; + + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + namespaces + .patch(TEST_NAMESPACE, &patch_params, &Patch::Apply(&ns)) + .await?; + + Ok(()) +} + +/// Delete the test namespace and all its resources +pub async fn cleanup_test_namespace(client: &Client) -> Result<(), kube::Error> { + use k8s_openapi::api::core::v1::Namespace; + + let namespaces: Api = Api::all(client.clone()); + + match namespaces + .delete(TEST_NAMESPACE, &DeleteParams::default()) + .await + { + Ok(_) => { + // Wait for namespace to be deleted + let lp = ListParams::default().fields(&format!("metadata.name={}", TEST_NAMESPACE)); + let _ = timeout(Duration::from_secs(60), async { + loop { + match namespaces.list(&lp).await { + Ok(list) if list.items.is_empty() => break, + _ => tokio::time::sleep(Duration::from_secs(1)).await, + } + } + }) + .await; + } + Err(kube::Error::Api(err)) if err.code == 404 => { + // Namespace doesn't exist, that's fine + } + Err(e) => return Err(e), + } + + Ok(()) +} + +/// Check if a CRD is established (ready to use) +pub async fn is_crd_established(client: &Client, crd_name: &str) -> bool { + let crds: Api = Api::all(client.clone()); + + match crds.get(crd_name).await { + Ok(crd) => { + if let Some(status) = crd.status + && let Some(conditions) = status.conditions + { + return conditions + .iter() + .any(|c| c.type_ == "Established" && c.status == "True"); + } + false + } + Err(_) => false, + } +} + +/// Wait for a CRD to be established +pub async fn wait_for_crd(client: &Client, crd_name: &str) -> Result<(), String> { + timeout(DEFAULT_TIMEOUT, async { + loop { + if is_crd_established(client, crd_name).await { + return; + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + }) + .await + .map_err(|_| format!("Timeout waiting for CRD {} to be established", crd_name)) +} + +/// Apply a resource to the cluster +pub async fn apply_resource( + client: &Client, + namespace: &str, + resource: &T, +) -> Result +where + T: Resource + + Clone + + serde::Serialize + + serde::de::DeserializeOwned + + std::fmt::Debug, + ::DynamicType: Default, +{ + let api: Api = Api::namespaced(client.clone(), namespace); + let name = resource.meta().name.clone().unwrap_or_default(); + + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + api.patch(&name, &patch_params, &Patch::Apply(resource)) + .await +} + +/// Delete a resource from the cluster +pub async fn delete_resource( + client: &Client, + namespace: &str, + name: &str, +) -> Result<(), kube::Error> +where + T: Resource + + Clone + + serde::Serialize + + serde::de::DeserializeOwned + + std::fmt::Debug, + ::DynamicType: Default, +{ + let api: Api = Api::namespaced(client.clone(), namespace); + + match api.delete(name, &DeleteParams::default()).await { + Ok(_) => Ok(()), + Err(kube::Error::Api(err)) if err.code == 404 => Ok(()), // Already deleted + Err(e) => Err(e), + } +} + +/// Wait for a resource to have a specific condition +pub async fn wait_for_condition( + client: &Client, + namespace: &str, + name: &str, + condition_fn: F, +) -> Result +where + T: Resource + + Clone + + serde::Serialize + + serde::de::DeserializeOwned + + std::fmt::Debug, + ::DynamicType: Default, + F: Fn(&T) -> bool, +{ + let api: Api = Api::namespaced(client.clone(), namespace); + + timeout(DEFAULT_TIMEOUT, async { + loop { + if let Ok(resource) = api.get(name).await + && condition_fn(&resource) + { + return Ok(resource); + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + }) + .await + .map_err(|_| format!("Timeout waiting for condition on {}", name))? +} + +/// Generate a unique test name to avoid conflicts between test runs +pub fn unique_name(prefix: &str) -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis(); + format!("{}-{}", prefix, timestamp % 100000) +} diff --git a/tests/integration/custom_format_crd.rs b/tests/integration/custom_format_crd.rs new file mode 100644 index 0000000..7d36a89 --- /dev/null +++ b/tests/integration/custom_format_crd.rs @@ -0,0 +1,230 @@ +//! Integration tests for the SonarrCustomFormat CRD + +use crate::common::*; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::api::{Api, DeleteParams, Patch, PatchParams}; +use sonarr_operator::crds::custom_format::{ + CustomFormatFields, CustomFormatImplementation, CustomFormatSpecification, + SonarrCustomFormatSpec, +}; +use sonarr_operator::crds::{SonarrCustomFormat, SonarrInstanceRef}; + +/// Test that the SonarrCustomFormat CRD is installed and established +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_sonarr_custom_format_crd_is_established() { + let client = test_client().await; + + assert!( + is_crd_established(&client, "sonarrcustomformats.devopsarr.io").await, + "SonarrCustomFormat CRD is not established - run 'make install' first" + ); +} + +/// Test creating a SonarrCustomFormat resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_create_sonarr_custom_format() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("custom-format-test"); + let cf = SonarrCustomFormat { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrCustomFormatSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + name: "4K HDR".to_string(), + include_custom_format_when_renaming: true, + specifications: vec![ + CustomFormatSpecification { + name: "4K".to_string(), + implementation: CustomFormatImplementation::ResolutionSpecification, + negate: false, + required: true, + fields: CustomFormatFields { + value: Some("2160".to_string()), + min: None, + max: None, + }, + }, + CustomFormatSpecification { + name: "HDR".to_string(), + implementation: CustomFormatImplementation::ReleaseTitleSpecification, + negate: false, + required: true, + fields: CustomFormatFields { + value: Some("HDR".to_string()), + min: None, + max: None, + }, + }, + ], + }, + status: None, + }; + + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + let result = api.patch(&name, &patch_params, &Patch::Apply(&cf)).await; + + assert!( + result.is_ok(), + "Failed to create SonarrCustomFormat: {:?}", + result.err() + ); + + let retrieved = api.get(&name).await; + assert!( + retrieved.is_ok(), + "Failed to get SonarrCustomFormat: {:?}", + retrieved.err() + ); + + let cf = retrieved.unwrap(); + assert_eq!(cf.spec.name, "4K HDR"); + assert!(cf.spec.include_custom_format_when_renaming); + assert_eq!(cf.spec.specifications.len(), 2); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test updating a SonarrCustomFormat resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_update_sonarr_custom_format() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("custom-format-update"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + // Create initial resource + let cf = SonarrCustomFormat { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrCustomFormatSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + name: "Original Format".to_string(), + include_custom_format_when_renaming: false, + specifications: vec![], + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&cf)) + .await + .expect("Failed to create SonarrCustomFormat"); + + // Update the resource + let updated = SonarrCustomFormat { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrCustomFormatSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + name: "Updated Format".to_string(), + include_custom_format_when_renaming: true, + specifications: vec![CustomFormatSpecification { + name: "x265".to_string(), + implementation: CustomFormatImplementation::ReleaseTitleSpecification, + negate: false, + required: true, + fields: CustomFormatFields { + value: Some("x265|HEVC".to_string()), + min: None, + max: None, + }, + }], + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&updated)) + .await + .expect("Failed to update SonarrCustomFormat"); + + let retrieved = api + .get(&name) + .await + .expect("Failed to get SonarrCustomFormat"); + assert_eq!(retrieved.spec.name, "Updated Format"); + assert!(retrieved.spec.include_custom_format_when_renaming); + assert_eq!(retrieved.spec.specifications.len(), 1); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test deleting a SonarrCustomFormat resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_delete_sonarr_custom_format() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("custom-format-delete"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + let cf = SonarrCustomFormat { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrCustomFormatSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + name: "To Be Deleted".to_string(), + include_custom_format_when_renaming: false, + specifications: vec![], + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&cf)) + .await + .expect("Failed to create SonarrCustomFormat"); + + let delete_result = api.delete(&name, &DeleteParams::default()).await; + assert!( + delete_result.is_ok(), + "Failed to delete SonarrCustomFormat: {:?}", + delete_result.err() + ); + + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + let get_result = api.get(&name).await; + assert!( + get_result.is_err(), + "SonarrCustomFormat should have been deleted" + ); +} diff --git a/tests/integration/delay_profile_crd.rs b/tests/integration/delay_profile_crd.rs new file mode 100644 index 0000000..3ef358c --- /dev/null +++ b/tests/integration/delay_profile_crd.rs @@ -0,0 +1,225 @@ +//! Integration tests for the SonarrDelayProfile CRD + +use crate::common::*; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::api::{Api, DeleteParams, Patch, PatchParams}; +use sonarr_operator::crds::delay_profile::{DownloadProtocol, SonarrDelayProfileSpec}; +use sonarr_operator::crds::{SonarrDelayProfile, SonarrInstanceRef}; + +/// Test that the SonarrDelayProfile CRD is installed and established +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_sonarr_delay_profile_crd_is_established() { + let client = test_client().await; + + assert!( + is_crd_established(&client, "sonarrdelayprofiles.devopsarr.io").await, + "SonarrDelayProfile CRD is not established - run 'make install' first" + ); +} + +/// Test creating a SonarrDelayProfile resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_create_sonarr_delay_profile() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("delay-profile-test"); + let profile = SonarrDelayProfile { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrDelayProfileSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + enable_usenet: true, + enable_torrent: true, + preferred_protocol: DownloadProtocol::Usenet, + usenet_delay: 0, + torrent_delay: 120, + bypass_if_highest_quality: true, + bypass_if_above_custom_format_score: false, + minimum_custom_format_score: 0, + order: 1, + tags: vec![], + }, + status: None, + }; + + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + let result = api + .patch(&name, &patch_params, &Patch::Apply(&profile)) + .await; + + assert!( + result.is_ok(), + "Failed to create SonarrDelayProfile: {:?}", + result.err() + ); + + let retrieved = api.get(&name).await; + assert!( + retrieved.is_ok(), + "Failed to get SonarrDelayProfile: {:?}", + retrieved.err() + ); + + let profile = retrieved.unwrap(); + assert!(profile.spec.enable_usenet); + assert_eq!(profile.spec.torrent_delay, 120); + assert!(profile.spec.bypass_if_highest_quality); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test updating a SonarrDelayProfile resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_update_sonarr_delay_profile() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("delay-profile-update"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + // Create initial resource + let profile = SonarrDelayProfile { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrDelayProfileSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + enable_usenet: true, + enable_torrent: false, + preferred_protocol: DownloadProtocol::Usenet, + usenet_delay: 0, + torrent_delay: 0, + bypass_if_highest_quality: false, + bypass_if_above_custom_format_score: false, + minimum_custom_format_score: 0, + order: 1, + tags: vec![], + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&profile)) + .await + .expect("Failed to create SonarrDelayProfile"); + + // Update the resource + let updated = SonarrDelayProfile { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrDelayProfileSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + enable_usenet: false, + enable_torrent: true, + preferred_protocol: DownloadProtocol::Torrent, + usenet_delay: 60, + torrent_delay: 30, + bypass_if_highest_quality: true, + bypass_if_above_custom_format_score: true, + minimum_custom_format_score: 100, + order: 2, + tags: vec![1, 2, 3], + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&updated)) + .await + .expect("Failed to update SonarrDelayProfile"); + + let retrieved = api + .get(&name) + .await + .expect("Failed to get SonarrDelayProfile"); + assert!(!retrieved.spec.enable_usenet); + assert!(retrieved.spec.enable_torrent); + assert_eq!(retrieved.spec.torrent_delay, 30); + assert_eq!(retrieved.spec.minimum_custom_format_score, 100); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test deleting a SonarrDelayProfile resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_delete_sonarr_delay_profile() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("delay-profile-delete"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + let profile = SonarrDelayProfile { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrDelayProfileSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + enable_usenet: true, + enable_torrent: true, + preferred_protocol: DownloadProtocol::Usenet, + usenet_delay: 0, + torrent_delay: 0, + bypass_if_highest_quality: false, + bypass_if_above_custom_format_score: false, + minimum_custom_format_score: 0, + order: 1, + tags: vec![], + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&profile)) + .await + .expect("Failed to create SonarrDelayProfile"); + + let delete_result = api.delete(&name, &DeleteParams::default()).await; + assert!( + delete_result.is_ok(), + "Failed to delete SonarrDelayProfile: {:?}", + delete_result.err() + ); + + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + let get_result = api.get(&name).await; + assert!( + get_result.is_err(), + "SonarrDelayProfile should have been deleted" + ); +} diff --git a/tests/integration/download_client_config_crd.rs b/tests/integration/download_client_config_crd.rs new file mode 100644 index 0000000..8e11d37 --- /dev/null +++ b/tests/integration/download_client_config_crd.rs @@ -0,0 +1,195 @@ +//! Integration tests for the SonarrDownloadClientConfig CRD + +use crate::common::*; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::api::{Api, DeleteParams, Patch, PatchParams}; +use sonarr_operator::crds::download_client_config::SonarrDownloadClientConfigSpec; +use sonarr_operator::crds::{SonarrDownloadClientConfig, SonarrInstanceRef}; + +/// Test that the SonarrDownloadClientConfig CRD is installed and established +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_sonarr_download_client_config_crd_is_established() { + let client = test_client().await; + + assert!( + is_crd_established(&client, "sonarrdownloadclientconfigs.devopsarr.io").await, + "SonarrDownloadClientConfig CRD is not established - run 'make install' first" + ); +} + +/// Test creating a SonarrDownloadClientConfig resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_create_sonarr_download_client_config() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("dlclient-cfg-test"); + let config = SonarrDownloadClientConfig { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrDownloadClientConfigSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + enable_completed_download_handling: Some(true), + auto_redownload_failed: Some(true), + auto_redownload_failed_from_interactive_search: Some(false), + download_client_working_folders: None, + }, + status: None, + }; + + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + let result = api + .patch(&name, &patch_params, &Patch::Apply(&config)) + .await; + + assert!( + result.is_ok(), + "Failed to create SonarrDownloadClientConfig: {:?}", + result.err() + ); + + let retrieved = api.get(&name).await; + assert!( + retrieved.is_ok(), + "Failed to get SonarrDownloadClientConfig: {:?}", + retrieved.err() + ); + + let config = retrieved.unwrap(); + assert_eq!(config.spec.enable_completed_download_handling, Some(true)); + assert_eq!(config.spec.auto_redownload_failed, Some(true)); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test updating a SonarrDownloadClientConfig resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_update_sonarr_download_client_config() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("dlclient-cfg-update"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + // Create initial resource + let config = SonarrDownloadClientConfig { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrDownloadClientConfigSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + enable_completed_download_handling: Some(false), + ..Default::default() + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&config)) + .await + .expect("Failed to create SonarrDownloadClientConfig"); + + // Update the resource + let updated = SonarrDownloadClientConfig { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrDownloadClientConfigSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + enable_completed_download_handling: Some(true), + auto_redownload_failed: Some(true), + ..Default::default() + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&updated)) + .await + .expect("Failed to update SonarrDownloadClientConfig"); + + let retrieved = api + .get(&name) + .await + .expect("Failed to get SonarrDownloadClientConfig"); + assert_eq!( + retrieved.spec.enable_completed_download_handling, + Some(true) + ); + assert_eq!(retrieved.spec.auto_redownload_failed, Some(true)); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test deleting a SonarrDownloadClientConfig resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_delete_sonarr_download_client_config() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("dlclient-cfg-delete"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + let config = SonarrDownloadClientConfig { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrDownloadClientConfigSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + ..Default::default() + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&config)) + .await + .expect("Failed to create SonarrDownloadClientConfig"); + + let delete_result = api.delete(&name, &DeleteParams::default()).await; + assert!( + delete_result.is_ok(), + "Failed to delete SonarrDownloadClientConfig: {:?}", + delete_result.err() + ); + + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + let get_result = api.get(&name).await; + assert!( + get_result.is_err(), + "SonarrDownloadClientConfig should have been deleted" + ); +} diff --git a/tests/integration/import_list_crd.rs b/tests/integration/import_list_crd.rs new file mode 100644 index 0000000..ac01185 --- /dev/null +++ b/tests/integration/import_list_crd.rs @@ -0,0 +1,236 @@ +//! Integration tests for the SonarrImportList CRD + +use crate::common::*; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::api::{Api, DeleteParams, Patch, PatchParams}; +use sonarr_operator::crds::import_list::{ + ImportListType, MonitorTypes, NewItemMonitorTypes, SeriesTypes, SonarrImportListSpec, +}; +use sonarr_operator::crds::{SonarrImportList, SonarrInstanceRef}; + +/// Test that the SonarrImportList CRD is installed and established +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_sonarr_import_list_crd_is_established() { + let client = test_client().await; + + assert!( + is_crd_established(&client, "sonarrimportlists.devopsarr.io").await, + "SonarrImportList CRD is not established - run 'make install' first" + ); +} + +/// Test creating a SonarrImportList resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_create_sonarr_import_list() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("import-list-test"); + let import_list = SonarrImportList { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrImportListSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + name: "Test Import List".to_string(), + list_type: ImportListType::TraktListImport, + enable_automatic_add: true, + search_for_missing_episodes: false, + should_monitor: MonitorTypes::All, + monitor_new_items: NewItemMonitorTypes::All, + root_folder_path: "/tv".to_string(), + quality_profile_id: 1, + series_type: SeriesTypes::Standard, + season_folder: true, + list_order: 0, + tags: vec![], + config: Default::default(), + }, + status: None, + }; + + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + let result = api + .patch(&name, &patch_params, &Patch::Apply(&import_list)) + .await; + + assert!( + result.is_ok(), + "Failed to create SonarrImportList: {:?}", + result.err() + ); + + let retrieved = api.get(&name).await; + assert!( + retrieved.is_ok(), + "Failed to get SonarrImportList: {:?}", + retrieved.err() + ); + + let import_list = retrieved.unwrap(); + assert_eq!(import_list.spec.name, "Test Import List"); + assert_eq!(import_list.spec.root_folder_path, "/tv"); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test updating a SonarrImportList resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_update_sonarr_import_list() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("import-list-update"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + // Create initial resource + let import_list = SonarrImportList { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrImportListSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + name: "Original Import List".to_string(), + list_type: ImportListType::TraktListImport, + enable_automatic_add: true, + search_for_missing_episodes: false, + should_monitor: MonitorTypes::All, + monitor_new_items: NewItemMonitorTypes::All, + root_folder_path: "/tv".to_string(), + quality_profile_id: 1, + series_type: SeriesTypes::Standard, + season_folder: true, + list_order: 0, + tags: vec![], + config: Default::default(), + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&import_list)) + .await + .expect("Failed to create SonarrImportList"); + + // Update the resource + let updated = SonarrImportList { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrImportListSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + name: "Updated Import List".to_string(), + list_type: ImportListType::PlexImport, + enable_automatic_add: false, + search_for_missing_episodes: true, + should_monitor: MonitorTypes::Future, + monitor_new_items: NewItemMonitorTypes::None, + root_folder_path: "/movies".to_string(), + quality_profile_id: 2, + series_type: SeriesTypes::Anime, + season_folder: false, + list_order: 1, + tags: vec![1, 2], + config: Default::default(), + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&updated)) + .await + .expect("Failed to update SonarrImportList"); + + let retrieved = api + .get(&name) + .await + .expect("Failed to get SonarrImportList"); + assert_eq!(retrieved.spec.name, "Updated Import List"); + assert_eq!(retrieved.spec.root_folder_path, "/movies"); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test deleting a SonarrImportList resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_delete_sonarr_import_list() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("import-list-delete"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + let import_list = SonarrImportList { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrImportListSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + name: "To Be Deleted".to_string(), + list_type: ImportListType::SonarrImport, + enable_automatic_add: true, + search_for_missing_episodes: false, + should_monitor: MonitorTypes::All, + monitor_new_items: NewItemMonitorTypes::All, + root_folder_path: "/tv".to_string(), + quality_profile_id: 1, + series_type: SeriesTypes::Standard, + season_folder: true, + list_order: 0, + tags: vec![], + config: Default::default(), + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&import_list)) + .await + .expect("Failed to create SonarrImportList"); + + let delete_result = api.delete(&name, &DeleteParams::default()).await; + assert!( + delete_result.is_ok(), + "Failed to delete SonarrImportList: {:?}", + delete_result.err() + ); + + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + let get_result = api.get(&name).await; + assert!( + get_result.is_err(), + "SonarrImportList should have been deleted" + ); +} diff --git a/tests/integration/indexer_config_crd.rs b/tests/integration/indexer_config_crd.rs new file mode 100644 index 0000000..34f6732 --- /dev/null +++ b/tests/integration/indexer_config_crd.rs @@ -0,0 +1,191 @@ +//! Integration tests for the SonarrIndexerConfig CRD + +use crate::common::*; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::api::{Api, DeleteParams, Patch, PatchParams}; +use sonarr_operator::crds::indexer_config::SonarrIndexerConfigSpec; +use sonarr_operator::crds::{SonarrIndexerConfig, SonarrInstanceRef}; + +/// Test that the SonarrIndexerConfig CRD is installed and established +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_sonarr_indexer_config_crd_is_established() { + let client = test_client().await; + + assert!( + is_crd_established(&client, "sonarrindexerconfigs.devopsarr.io").await, + "SonarrIndexerConfig CRD is not established - run 'make install' first" + ); +} + +/// Test creating a SonarrIndexerConfig resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_create_sonarr_indexer_config() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("indexer-cfg-test"); + let config = SonarrIndexerConfig { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrIndexerConfigSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + minimum_age: Some(0), + retention: Some(0), + maximum_size: Some(0), + rss_sync_interval: Some(60), + }, + status: None, + }; + + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + let result = api + .patch(&name, &patch_params, &Patch::Apply(&config)) + .await; + + assert!( + result.is_ok(), + "Failed to create SonarrIndexerConfig: {:?}", + result.err() + ); + + let retrieved = api.get(&name).await; + assert!( + retrieved.is_ok(), + "Failed to get SonarrIndexerConfig: {:?}", + retrieved.err() + ); + + let config = retrieved.unwrap(); + assert_eq!(config.spec.rss_sync_interval, Some(60)); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test updating a SonarrIndexerConfig resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_update_sonarr_indexer_config() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("indexer-cfg-update"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + // Create initial resource + let config = SonarrIndexerConfig { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrIndexerConfigSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + rss_sync_interval: Some(30), + ..Default::default() + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&config)) + .await + .expect("Failed to create SonarrIndexerConfig"); + + // Update the resource + let updated = SonarrIndexerConfig { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrIndexerConfigSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + rss_sync_interval: Some(120), + maximum_size: Some(500), + ..Default::default() + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&updated)) + .await + .expect("Failed to update SonarrIndexerConfig"); + + let retrieved = api + .get(&name) + .await + .expect("Failed to get SonarrIndexerConfig"); + assert_eq!(retrieved.spec.rss_sync_interval, Some(120)); + assert_eq!(retrieved.spec.maximum_size, Some(500)); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test deleting a SonarrIndexerConfig resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_delete_sonarr_indexer_config() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("indexer-cfg-delete"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + let config = SonarrIndexerConfig { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrIndexerConfigSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + ..Default::default() + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&config)) + .await + .expect("Failed to create SonarrIndexerConfig"); + + let delete_result = api.delete(&name, &DeleteParams::default()).await; + assert!( + delete_result.is_ok(), + "Failed to delete SonarrIndexerConfig: {:?}", + delete_result.err() + ); + + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + let get_result = api.get(&name).await; + assert!( + get_result.is_err(), + "SonarrIndexerConfig should have been deleted" + ); +} diff --git a/tests/integration/language_profile_crd.rs b/tests/integration/language_profile_crd.rs new file mode 100644 index 0000000..252571f --- /dev/null +++ b/tests/integration/language_profile_crd.rs @@ -0,0 +1,223 @@ +//! Integration tests for the SonarrLanguageProfile CRD + +use crate::common::*; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::api::{Api, DeleteParams, Patch, PatchParams}; +use sonarr_operator::crds::language_profile::{ + LanguageItem, LanguageType, SonarrLanguageProfileSpec, +}; +use sonarr_operator::crds::{SonarrInstanceRef, SonarrLanguageProfile}; + +/// Test that the SonarrLanguageProfile CRD is installed and established +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_sonarr_language_profile_crd_is_established() { + let client = test_client().await; + + assert!( + is_crd_established(&client, "sonarrlanguageprofiles.devopsarr.io").await, + "SonarrLanguageProfile CRD is not established - run 'make install' first" + ); +} + +/// Test creating a SonarrLanguageProfile resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_create_sonarr_language_profile() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("lang-profile-test"); + let profile = SonarrLanguageProfile { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrLanguageProfileSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + name: "Test Language Profile".to_string(), + upgrade_allowed: true, + cutoff_language: LanguageType::English, + languages: vec![ + LanguageItem { + language: LanguageType::English, + allowed: true, + }, + LanguageItem { + language: LanguageType::French, + allowed: true, + }, + ], + }, + status: None, + }; + + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + let result = api + .patch(&name, &patch_params, &Patch::Apply(&profile)) + .await; + + assert!( + result.is_ok(), + "Failed to create SonarrLanguageProfile: {:?}", + result.err() + ); + + let retrieved = api.get(&name).await; + assert!( + retrieved.is_ok(), + "Failed to get SonarrLanguageProfile: {:?}", + retrieved.err() + ); + + let profile = retrieved.unwrap(); + assert_eq!(profile.spec.name, "Test Language Profile"); + assert!(profile.spec.upgrade_allowed); + assert_eq!(profile.spec.languages.len(), 2); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test updating a SonarrLanguageProfile resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_update_sonarr_language_profile() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("lang-profile-update"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + // Create initial resource + let profile = SonarrLanguageProfile { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrLanguageProfileSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + name: "Original Profile".to_string(), + upgrade_allowed: false, + cutoff_language: LanguageType::English, + languages: vec![LanguageItem { + language: LanguageType::English, + allowed: true, + }], + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&profile)) + .await + .expect("Failed to create SonarrLanguageProfile"); + + // Update the resource + let updated = SonarrLanguageProfile { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrLanguageProfileSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + name: "Updated Profile".to_string(), + upgrade_allowed: true, + cutoff_language: LanguageType::Japanese, + languages: vec![ + LanguageItem { + language: LanguageType::Japanese, + allowed: true, + }, + LanguageItem { + language: LanguageType::English, + allowed: true, + }, + ], + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&updated)) + .await + .expect("Failed to update SonarrLanguageProfile"); + + let retrieved = api + .get(&name) + .await + .expect("Failed to get SonarrLanguageProfile"); + assert_eq!(retrieved.spec.name, "Updated Profile"); + assert!(retrieved.spec.upgrade_allowed); + assert_eq!(retrieved.spec.languages.len(), 2); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test deleting a SonarrLanguageProfile resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_delete_sonarr_language_profile() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("lang-profile-delete"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + let profile = SonarrLanguageProfile { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrLanguageProfileSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + name: "To Be Deleted".to_string(), + upgrade_allowed: false, + cutoff_language: LanguageType::English, + languages: vec![], + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&profile)) + .await + .expect("Failed to create SonarrLanguageProfile"); + + let delete_result = api.delete(&name, &DeleteParams::default()).await; + assert!( + delete_result.is_ok(), + "Failed to delete SonarrLanguageProfile: {:?}", + delete_result.err() + ); + + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + let get_result = api.get(&name).await; + assert!( + get_result.is_err(), + "SonarrLanguageProfile should have been deleted" + ); +} diff --git a/tests/integration/main.rs b/tests/integration/main.rs new file mode 100644 index 0000000..3bfc3e2 --- /dev/null +++ b/tests/integration/main.rs @@ -0,0 +1,25 @@ +//! Integration tests for the Sonarr Kubernetes Operator +//! +//! These tests require a running Kubernetes cluster accessible via the current kubeconfig context. +//! Run with: `cargo test --test integration -- --ignored` +//! +//! Prerequisites: +//! 1. A running Kubernetes cluster (e.g., kind, k3d, minikube) +//! 2. CRDs installed: `make install` +//! 3. Current kubeconfig context set to the test cluster + +mod auto_tag_crd; +mod common; +mod custom_format_crd; +mod delay_profile_crd; +mod download_client_config_crd; +mod import_list_crd; +mod indexer_config_crd; +mod language_profile_crd; +mod media_management_config_crd; +mod metadata_crd; +mod naming_config_crd; +mod quality_definition_crd; +mod root_folder_crd; +mod sonarr_crd; +mod tag_crd; diff --git a/tests/integration/media_management_config_crd.rs b/tests/integration/media_management_config_crd.rs new file mode 100644 index 0000000..c331cc5 --- /dev/null +++ b/tests/integration/media_management_config_crd.rs @@ -0,0 +1,273 @@ +//! Integration tests for the SonarrMediaManagementConfig CRD + +use crate::common::*; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::api::{Api, DeleteParams, Patch, PatchParams}; +use sonarr_operator::crds::media_management_config::SonarrMediaManagementConfigSpec; +use sonarr_operator::crds::{SonarrInstanceRef, SonarrMediaManagementConfig}; + +/// Test that the SonarrMediaManagementConfig CRD is installed and established +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_sonarr_media_management_config_crd_is_established() { + let client = test_client().await; + + assert!( + is_crd_established(&client, "sonarrmediamanagementconfigs.devopsarr.io").await, + "SonarrMediaManagementConfig CRD is not established - run 'make install' first" + ); +} + +/// Test creating a SonarrMediaManagementConfig resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_create_sonarr_media_management_config() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("media-mgmt-test"); + let config = SonarrMediaManagementConfig { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrMediaManagementConfigSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + recycle_bin: Some("/data/recycle".to_string()), + recycle_bin_cleanup_days: Some(7), + copy_using_hardlinks: Some(true), + create_empty_series_folders: Some(false), + delete_empty_folders: Some(true), + ..Default::default() + }, + status: None, + }; + + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + let result = api + .patch(&name, &patch_params, &Patch::Apply(&config)) + .await; + + assert!( + result.is_ok(), + "Failed to create SonarrMediaManagementConfig: {:?}", + result.err() + ); + + let retrieved = api.get(&name).await; + assert!( + retrieved.is_ok(), + "Failed to get SonarrMediaManagementConfig: {:?}", + retrieved.err() + ); + + let config = retrieved.unwrap(); + assert_eq!(config.spec.recycle_bin, Some("/data/recycle".to_string())); + assert_eq!(config.spec.recycle_bin_cleanup_days, Some(7)); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test updating a SonarrMediaManagementConfig resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_update_sonarr_media_management_config() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("media-mgmt-update"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + // Create initial resource + let config = SonarrMediaManagementConfig { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrMediaManagementConfigSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + recycle_bin_cleanup_days: Some(7), + ..Default::default() + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&config)) + .await + .expect("Failed to create SonarrMediaManagementConfig"); + + // Update the resource + let updated = SonarrMediaManagementConfig { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrMediaManagementConfigSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + recycle_bin_cleanup_days: Some(14), + copy_using_hardlinks: Some(false), + ..Default::default() + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&updated)) + .await + .expect("Failed to update SonarrMediaManagementConfig"); + + let retrieved = api + .get(&name) + .await + .expect("Failed to get SonarrMediaManagementConfig"); + assert_eq!(retrieved.spec.recycle_bin_cleanup_days, Some(14)); + assert_eq!(retrieved.spec.copy_using_hardlinks, Some(false)); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test deleting a SonarrMediaManagementConfig resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_delete_sonarr_media_management_config() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("media-mgmt-delete"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + let config = SonarrMediaManagementConfig { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrMediaManagementConfigSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + ..Default::default() + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&config)) + .await + .expect("Failed to create SonarrMediaManagementConfig"); + + let delete_result = api.delete(&name, &DeleteParams::default()).await; + assert!( + delete_result.is_ok(), + "Failed to delete SonarrMediaManagementConfig: {:?}", + delete_result.err() + ); + + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + let get_result = api.get(&name).await; + assert!( + get_result.is_err(), + "SonarrMediaManagementConfig should have been deleted" + ); +} + +/// Test that only one SonarrMediaManagementConfig per Sonarr instance can be created +/// The second one should be marked as conflict by the controller +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_singleton_constraint() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let instance_name = unique_name("singleton-instance"); + let name1 = unique_name("media-mgmt-first"); + let name2 = unique_name("media-mgmt-second"); + + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + // Create first config + let config1 = SonarrMediaManagementConfig { + metadata: ObjectMeta { + name: Some(name1.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrMediaManagementConfigSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: instance_name.clone(), + namespace: None, + }, + recycle_bin_cleanup_days: Some(7), + ..Default::default() + }, + status: None, + }; + + api.patch(&name1, &patch_params, &Patch::Apply(&config1)) + .await + .expect("Failed to create first SonarrMediaManagementConfig"); + + // Create second config for the same instance + let config2 = SonarrMediaManagementConfig { + metadata: ObjectMeta { + name: Some(name2.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrMediaManagementConfigSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: instance_name.clone(), + namespace: None, + }, + recycle_bin_cleanup_days: Some(14), + ..Default::default() + }, + status: None, + }; + + // This should succeed at the K8s level (resource is created) + // but the controller should mark it as conflict + let result = api + .patch(&name2, &patch_params, &Patch::Apply(&config2)) + .await; + assert!( + result.is_ok(), + "Failed to create second SonarrMediaManagementConfig: {:?}", + result.err() + ); + + // Both resources should exist in K8s + let retrieved1 = api.get(&name1).await; + let retrieved2 = api.get(&name2).await; + assert!(retrieved1.is_ok(), "First config should exist"); + assert!(retrieved2.is_ok(), "Second config should exist"); + + // Cleanup + let _ = api.delete(&name1, &DeleteParams::default()).await; + let _ = api.delete(&name2, &DeleteParams::default()).await; +} diff --git a/tests/integration/metadata_crd.rs b/tests/integration/metadata_crd.rs new file mode 100644 index 0000000..911d1e9 --- /dev/null +++ b/tests/integration/metadata_crd.rs @@ -0,0 +1,213 @@ +//! Integration tests for the SonarrMetadata CRD + +use crate::common::*; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::api::{Api, DeleteParams, Patch, PatchParams}; +use sonarr_operator::crds::metadata::{MetadataConfig, MetadataType, SonarrMetadataSpec}; +use sonarr_operator::crds::{SonarrInstanceRef, SonarrMetadata}; + +/// Test that the SonarrMetadata CRD is installed and established +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_sonarr_metadata_crd_is_established() { + let client = test_client().await; + + assert!( + is_crd_established(&client, "sonarrmetadatas.devopsarr.io").await, + "SonarrMetadata CRD is not established - run 'make install' first" + ); +} + +/// Test creating a SonarrMetadata resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_create_sonarr_metadata() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("metadata-test"); + let metadata_res = SonarrMetadata { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrMetadataSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + name: "Kodi Metadata".to_string(), + metadata_type: MetadataType::XbmcMetadata, + enable: true, + tags: vec![], + config: MetadataConfig { + series_metadata: true, + series_metadata_url: false, + episode_metadata: true, + series_images: true, + season_images: true, + episode_images: false, + }, + }, + status: None, + }; + + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + let result = api + .patch(&name, &patch_params, &Patch::Apply(&metadata_res)) + .await; + + assert!( + result.is_ok(), + "Failed to create SonarrMetadata: {:?}", + result.err() + ); + + let retrieved = api.get(&name).await; + assert!( + retrieved.is_ok(), + "Failed to get SonarrMetadata: {:?}", + retrieved.err() + ); + + let metadata_res = retrieved.unwrap(); + assert_eq!(metadata_res.spec.name, "Kodi Metadata"); + assert!(metadata_res.spec.enable); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test updating a SonarrMetadata resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_update_sonarr_metadata() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("metadata-update"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + // Create initial resource + let metadata_res = SonarrMetadata { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrMetadataSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + name: "Original Metadata".to_string(), + metadata_type: MetadataType::XbmcMetadata, + enable: true, + tags: vec![], + config: MetadataConfig::default(), + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&metadata_res)) + .await + .expect("Failed to create SonarrMetadata"); + + // Update the resource + let updated = SonarrMetadata { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrMetadataSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + name: "Updated Metadata".to_string(), + metadata_type: MetadataType::RoksboxMetadata, + enable: false, + tags: vec![1], + config: MetadataConfig { + series_metadata: false, + series_metadata_url: true, + episode_metadata: false, + series_images: false, + season_images: false, + episode_images: true, + }, + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&updated)) + .await + .expect("Failed to update SonarrMetadata"); + + let retrieved = api.get(&name).await.expect("Failed to get SonarrMetadata"); + assert_eq!(retrieved.spec.name, "Updated Metadata"); + assert!(!retrieved.spec.enable); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test deleting a SonarrMetadata resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_delete_sonarr_metadata() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("metadata-delete"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + let metadata_res = SonarrMetadata { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrMetadataSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + name: "To Be Deleted".to_string(), + metadata_type: MetadataType::WdtvMetadata, + enable: true, + tags: vec![], + config: MetadataConfig::default(), + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&metadata_res)) + .await + .expect("Failed to create SonarrMetadata"); + + let delete_result = api.delete(&name, &DeleteParams::default()).await; + assert!( + delete_result.is_ok(), + "Failed to delete SonarrMetadata: {:?}", + delete_result.err() + ); + + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + let get_result = api.get(&name).await; + assert!( + get_result.is_err(), + "SonarrMetadata should have been deleted" + ); +} diff --git a/tests/integration/naming_config_crd.rs b/tests/integration/naming_config_crd.rs new file mode 100644 index 0000000..ca5f964 --- /dev/null +++ b/tests/integration/naming_config_crd.rs @@ -0,0 +1,201 @@ +//! Integration tests for the SonarrNamingConfig CRD + +use crate::common::*; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::api::{Api, DeleteParams, Patch, PatchParams}; +use sonarr_operator::crds::naming_config::SonarrNamingConfigSpec; +use sonarr_operator::crds::{SonarrInstanceRef, SonarrNamingConfig}; + +/// Test that the SonarrNamingConfig CRD is installed and established +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_sonarr_naming_config_crd_is_established() { + let client = test_client().await; + + assert!( + is_crd_established(&client, "sonarrnamingconfigs.devopsarr.io").await, + "SonarrNamingConfig CRD is not established - run 'make install' first" + ); +} + +/// Test creating a SonarrNamingConfig resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_create_sonarr_naming_config() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("naming-test"); + let config = SonarrNamingConfig { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrNamingConfigSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + rename_episodes: Some(true), + replace_illegal_characters: Some(true), + standard_episode_format: Some( + "{Series Title} - S{season:00}E{episode:00} - {Episode Title}".to_string(), + ), + season_folder_format: Some("Season {season}".to_string()), + ..Default::default() + }, + status: None, + }; + + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + let result = api + .patch(&name, &patch_params, &Patch::Apply(&config)) + .await; + + assert!( + result.is_ok(), + "Failed to create SonarrNamingConfig: {:?}", + result.err() + ); + + let retrieved = api.get(&name).await; + assert!( + retrieved.is_ok(), + "Failed to get SonarrNamingConfig: {:?}", + retrieved.err() + ); + + let config = retrieved.unwrap(); + assert_eq!(config.spec.rename_episodes, Some(true)); + assert_eq!( + config.spec.season_folder_format, + Some("Season {season}".to_string()) + ); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test updating a SonarrNamingConfig resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_update_sonarr_naming_config() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("naming-update"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + // Create initial resource + let config = SonarrNamingConfig { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrNamingConfigSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + rename_episodes: Some(false), + ..Default::default() + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&config)) + .await + .expect("Failed to create SonarrNamingConfig"); + + // Update the resource + let updated = SonarrNamingConfig { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrNamingConfigSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + rename_episodes: Some(true), + standard_episode_format: Some("{Series Title} - {Episode Title}".to_string()), + ..Default::default() + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&updated)) + .await + .expect("Failed to update SonarrNamingConfig"); + + let retrieved = api + .get(&name) + .await + .expect("Failed to get SonarrNamingConfig"); + assert_eq!(retrieved.spec.rename_episodes, Some(true)); + assert_eq!( + retrieved.spec.standard_episode_format, + Some("{Series Title} - {Episode Title}".to_string()) + ); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test deleting a SonarrNamingConfig resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_delete_sonarr_naming_config() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("naming-delete"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + let config = SonarrNamingConfig { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrNamingConfigSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + ..Default::default() + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&config)) + .await + .expect("Failed to create SonarrNamingConfig"); + + let delete_result = api.delete(&name, &DeleteParams::default()).await; + assert!( + delete_result.is_ok(), + "Failed to delete SonarrNamingConfig: {:?}", + delete_result.err() + ); + + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + let get_result = api.get(&name).await; + assert!( + get_result.is_err(), + "SonarrNamingConfig should have been deleted" + ); +} diff --git a/tests/integration/quality_definition_crd.rs b/tests/integration/quality_definition_crd.rs new file mode 100644 index 0000000..6db1e42 --- /dev/null +++ b/tests/integration/quality_definition_crd.rs @@ -0,0 +1,273 @@ +//! Integration tests for the SonarrQualityDefinition CRD + +use crate::common::*; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::api::{Api, DeleteParams, Patch, PatchParams}; +use sonarr_operator::crds::quality_definition::{QualityName, SonarrQualityDefinitionSpec}; +use sonarr_operator::crds::{SonarrInstanceRef, SonarrQualityDefinition}; + +/// Test that the SonarrQualityDefinition CRD is installed and established +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_sonarr_quality_definition_crd_is_established() { + let client = test_client().await; + + assert!( + is_crd_established(&client, "sonarrqualitydefinitions.devopsarr.io").await, + "SonarrQualityDefinition CRD is not established - run 'make install' first" + ); +} + +/// Test creating a SonarrQualityDefinition resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_create_sonarr_quality_definition() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("quality-def-test"); + let qd = SonarrQualityDefinition { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrQualityDefinitionSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + quality_name: QualityName::Bluray1080p, + title: Some("Bluray 1080p Custom".to_string()), + min_size: Some(10.0), + max_size: Some(100.0), + preferred_size: Some(50.0), + }, + status: None, + }; + + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + let result = api.patch(&name, &patch_params, &Patch::Apply(&qd)).await; + + assert!( + result.is_ok(), + "Failed to create SonarrQualityDefinition: {:?}", + result.err() + ); + + let retrieved = api.get(&name).await; + assert!( + retrieved.is_ok(), + "Failed to get SonarrQualityDefinition: {:?}", + retrieved.err() + ); + + let qd = retrieved.unwrap(); + assert_eq!(qd.spec.title, Some("Bluray 1080p Custom".to_string())); + assert_eq!(qd.spec.min_size, Some(10.0)); + assert_eq!(qd.spec.max_size, Some(100.0)); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test updating a SonarrQualityDefinition resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_update_sonarr_quality_definition() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("quality-def-update"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + // Create initial resource + let qd = SonarrQualityDefinition { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrQualityDefinitionSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + quality_name: QualityName::Webdl1080p, + title: None, + min_size: Some(5.0), + max_size: Some(50.0), + preferred_size: Some(25.0), + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&qd)) + .await + .expect("Failed to create SonarrQualityDefinition"); + + // Update the resource + let updated = SonarrQualityDefinition { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrQualityDefinitionSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + quality_name: QualityName::Webdl1080p, + title: Some("WEB-DL 1080p Updated".to_string()), + min_size: Some(10.0), + max_size: Some(80.0), + preferred_size: Some(40.0), + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&updated)) + .await + .expect("Failed to update SonarrQualityDefinition"); + + let retrieved = api + .get(&name) + .await + .expect("Failed to get SonarrQualityDefinition"); + assert_eq!( + retrieved.spec.title, + Some("WEB-DL 1080p Updated".to_string()) + ); + assert_eq!(retrieved.spec.min_size, Some(10.0)); + assert_eq!(retrieved.spec.max_size, Some(80.0)); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test deleting a SonarrQualityDefinition resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_delete_sonarr_quality_definition() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("quality-def-delete"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + let qd = SonarrQualityDefinition { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrQualityDefinitionSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + quality_name: QualityName::Hdtv720p, + title: None, + min_size: None, + max_size: None, + preferred_size: None, + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&qd)) + .await + .expect("Failed to create SonarrQualityDefinition"); + + let delete_result = api.delete(&name, &DeleteParams::default()).await; + assert!( + delete_result.is_ok(), + "Failed to delete SonarrQualityDefinition: {:?}", + delete_result.err() + ); + + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + let get_result = api.get(&name).await; + assert!( + get_result.is_err(), + "SonarrQualityDefinition should have been deleted" + ); +} + +/// Test configuring different quality levels +/// Note: Quality definitions already exist in Sonarr, the CRD is used to configure them +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_configure_different_quality_levels() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + // Test configuring different quality levels - each maps to a pre-existing Sonarr quality + let qualities = vec![ + ("sdtv", QualityName::Sdtv, 1.0, 10.0), + ("hdtv720p", QualityName::Hdtv720p, 5.0, 50.0), + ("webdl1080p", QualityName::Webdl1080p, 10.0, 80.0), + ("bluray2160p", QualityName::Bluray2160p, 30.0, 200.0), + ]; + + let mut names = Vec::new(); + + for (quality_slug, quality, min, max) in &qualities { + let name = unique_name(&format!("quality-{}", quality_slug)); + names.push(name.clone()); + + let qd = SonarrQualityDefinition { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrQualityDefinitionSpec { + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + quality_name: quality.clone(), + title: None, + min_size: Some(*min), + max_size: Some(*max), + preferred_size: Some((min + max) / 2.0), + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&qd)) + .await + .expect("Failed to create quality definition"); + } + + // Verify all were created + for name in &names { + let retrieved = api.get(name).await; + assert!( + retrieved.is_ok(), + "Failed to get quality definition: {}", + name + ); + } + + // Cleanup + for name in &names { + let _ = api.delete(name, &DeleteParams::default()).await; + } +} diff --git a/tests/integration/root_folder_crd.rs b/tests/integration/root_folder_crd.rs new file mode 100644 index 0000000..3e06f42 --- /dev/null +++ b/tests/integration/root_folder_crd.rs @@ -0,0 +1,247 @@ +//! Integration tests for the SonarrRootFolder CRD + +use crate::common::*; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::api::{Api, DeleteParams, Patch, PatchParams}; +use sonarr_operator::crds::{SonarrInstanceRef, SonarrRootFolder, SonarrRootFolderSpec}; + +/// Test that the SonarrRootFolder CRD is installed and established +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_sonarr_root_folder_crd_is_established() { + let client = test_client().await; + + assert!( + is_crd_established(&client, "sonarrrootfolders.devopsarr.io").await, + "SonarrRootFolder CRD is not established - run 'make install' first" + ); +} + +/// Test creating a SonarrRootFolder resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_create_sonarr_root_folder() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("rootfolder-test"); + let root_folder = SonarrRootFolder { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrRootFolderSpec { + path: "/tv".to_string(), + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + }, + status: None, + }; + + // Create the resource + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + let result = api + .patch(&name, &patch_params, &Patch::Apply(&root_folder)) + .await; + + assert!( + result.is_ok(), + "Failed to create SonarrRootFolder: {:?}", + result.err() + ); + + // Verify it exists + let retrieved = api.get(&name).await; + assert!( + retrieved.is_ok(), + "Failed to get SonarrRootFolder: {:?}", + retrieved.err() + ); + + let rf = retrieved.unwrap(); + assert_eq!(rf.spec.path, "/tv"); + assert_eq!(rf.spec.sonarr_instance_ref.name, "test-sonarr"); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test creating root folders with different paths +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_create_multiple_root_folders() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + let paths = ["/tv/shows", "/tv/anime", "/tv/documentaries"]; + let mut names = Vec::new(); + + for (i, path) in paths.iter().enumerate() { + let name = unique_name(&format!("rootfolder-multi-{}", i)); + names.push(name.clone()); + + let root_folder = SonarrRootFolder { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + labels: Some([("test-group".to_string(), "multi-rf-test".to_string())].into()), + ..Default::default() + }, + spec: SonarrRootFolderSpec { + path: path.to_string(), + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&root_folder)) + .await + .expect("Failed to create root folder"); + } + + // List with label selector + let lp = kube::api::ListParams::default().labels("test-group=multi-rf-test"); + let list = api.list(&lp).await.expect("Failed to list root folders"); + + assert!( + list.items.len() >= 3, + "Expected at least 3 root folders, got {}", + list.items.len() + ); + + // Cleanup + for name in &names { + let _ = api.delete(name, &DeleteParams::default()).await; + } +} + +/// Test updating a SonarrRootFolder path +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_update_sonarr_root_folder() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("rootfolder-update"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + // Create initial resource + let root_folder = SonarrRootFolder { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrRootFolderSpec { + path: "/old/path".to_string(), + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&root_folder)) + .await + .expect("Failed to create SonarrRootFolder"); + + // Update the resource + let updated_rf = SonarrRootFolder { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrRootFolderSpec { + path: "/new/path".to_string(), + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&updated_rf)) + .await + .expect("Failed to update SonarrRootFolder"); + + // Verify the update + let retrieved = api + .get(&name) + .await + .expect("Failed to get SonarrRootFolder"); + assert_eq!(retrieved.spec.path, "/new/path"); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test deleting a SonarrRootFolder resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_delete_sonarr_root_folder() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("rootfolder-delete"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + // Create resource + let root_folder = SonarrRootFolder { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrRootFolderSpec { + path: "/to-be-deleted".to_string(), + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&root_folder)) + .await + .expect("Failed to create SonarrRootFolder"); + + // Delete the resource + let delete_result = api.delete(&name, &DeleteParams::default()).await; + assert!( + delete_result.is_ok(), + "Failed to delete SonarrRootFolder: {:?}", + delete_result.err() + ); + + // Verify it's deleted + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + let get_result = api.get(&name).await; + assert!( + get_result.is_err(), + "SonarrRootFolder should have been deleted" + ); +} diff --git a/tests/integration/sonarr_crd.rs b/tests/integration/sonarr_crd.rs new file mode 100644 index 0000000..f419ad0 --- /dev/null +++ b/tests/integration/sonarr_crd.rs @@ -0,0 +1,389 @@ +//! Integration tests for the Sonarr CRD +//! +//! These tests verify that Sonarr CRDs can be created, read, updated, and deleted +//! in a real Kubernetes cluster. + +use crate::common::*; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::api::{Api, DeleteParams, Patch, PatchParams}; +use sonarr_operator::crds::{ServiceConfig, Sonarr, SonarrSpec, StorageConfig}; + +/// Test that the Sonarr CRD is installed and established +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_sonarr_crd_is_established() { + let client = test_client().await; + + assert!( + is_crd_established(&client, "sonarrs.devopsarr.io").await, + "Sonarr CRD is not established - run 'make install' first" + ); +} + +/// Test creating a minimal Sonarr resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_create_minimal_sonarr() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("sonarr-minimal"); + let sonarr = Sonarr { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrSpec::default(), + status: None, + }; + + // Create the resource + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + let result = api + .patch(&name, &patch_params, &Patch::Apply(&sonarr)) + .await; + + assert!( + result.is_ok(), + "Failed to create Sonarr: {:?}", + result.err() + ); + + // Verify it exists + let retrieved = api.get(&name).await; + assert!( + retrieved.is_ok(), + "Failed to get Sonarr: {:?}", + retrieved.err() + ); + + let sonarr = retrieved.unwrap(); + assert_eq!(sonarr.spec.image, "lscr.io/linuxserver/sonarr:latest"); + assert_eq!(sonarr.spec.replicas, 1); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test creating a Sonarr resource with custom configuration +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_create_sonarr_with_custom_config() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("sonarr-custom"); + let sonarr = Sonarr { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrSpec { + image: "lscr.io/linuxserver/sonarr:develop".to_string(), + replicas: 1, + service: ServiceConfig { + port: 9090, + container_port: 8989, + service_type: "ClusterIP".to_string(), + ..Default::default() + }, + storage: StorageConfig { + size: "5Gi".to_string(), + ..Default::default() + }, + ..Default::default() + }, + status: None, + }; + + // Create the resource + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + let result = api + .patch(&name, &patch_params, &Patch::Apply(&sonarr)) + .await; + + assert!( + result.is_ok(), + "Failed to create Sonarr: {:?}", + result.err() + ); + + // Verify custom values + let retrieved = api.get(&name).await.expect("Failed to get Sonarr"); + assert_eq!(retrieved.spec.image, "lscr.io/linuxserver/sonarr:develop"); + assert_eq!(retrieved.spec.service.port, 9090); + assert_eq!(retrieved.spec.storage.size, "5Gi"); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test updating a Sonarr resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_update_sonarr() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("sonarr-update"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + // Create initial resource + let sonarr = Sonarr { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrSpec { + image: "lscr.io/linuxserver/sonarr:latest".to_string(), + ..Default::default() + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&sonarr)) + .await + .expect("Failed to create Sonarr"); + + // Update the resource + let updated_sonarr = Sonarr { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrSpec { + image: "lscr.io/linuxserver/sonarr:develop".to_string(), + ..Default::default() + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&updated_sonarr)) + .await + .expect("Failed to update Sonarr"); + + // Verify the update + let retrieved = api.get(&name).await.expect("Failed to get Sonarr"); + assert_eq!(retrieved.spec.image, "lscr.io/linuxserver/sonarr:develop"); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test deleting a Sonarr resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_delete_sonarr() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("sonarr-delete"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + // Create resource + let sonarr = Sonarr { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrSpec::default(), + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&sonarr)) + .await + .expect("Failed to create Sonarr"); + + // Delete the resource + let delete_result = api.delete(&name, &DeleteParams::default()).await; + assert!( + delete_result.is_ok(), + "Failed to delete Sonarr: {:?}", + delete_result.err() + ); + + // Verify it's deleted (may take a moment) + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + let get_result = api.get(&name).await; + assert!(get_result.is_err(), "Sonarr should have been deleted"); +} + +/// Test listing Sonarr resources +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_list_sonarrs() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + // Create a few resources + let names: Vec = (0..3) + .map(|i| unique_name(&format!("sonarr-list-{}", i))) + .collect(); + + for name in &names { + let sonarr = Sonarr { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + labels: Some([("test-group".to_string(), "list-test".to_string())].into()), + ..Default::default() + }, + spec: SonarrSpec::default(), + status: None, + }; + api.patch(name, &patch_params, &Patch::Apply(&sonarr)) + .await + .expect("Failed to create Sonarr"); + } + + // List with label selector + let lp = kube::api::ListParams::default().labels("test-group=list-test"); + let list = api.list(&lp).await.expect("Failed to list Sonarrs"); + + assert!( + list.items.len() >= 3, + "Expected at least 3 Sonarrs, got {}", + list.items.len() + ); + + // Cleanup + for name in &names { + let _ = api.delete(name, &DeleteParams::default()).await; + } +} + +/// Test that validation rejects invalid Sonarr specs +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_sonarr_with_ingress_config() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("sonarr-ingress"); + let sonarr = Sonarr { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrSpec { + ingress: Some(sonarr_operator::crds::sonarr::IngressConfig { + enabled: true, + host: "sonarr.example.com".to_string(), + path: "/".to_string(), + path_type: "Prefix".to_string(), + ingress_class_name: Some("nginx".to_string()), + tls: None, + annotations: Default::default(), + }), + ..Default::default() + }, + status: None, + }; + + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + let result = api + .patch(&name, &patch_params, &Patch::Apply(&sonarr)) + .await; + + assert!( + result.is_ok(), + "Failed to create Sonarr with ingress config: {:?}", + result.err() + ); + + // Verify ingress config + let retrieved = api.get(&name).await.expect("Failed to get Sonarr"); + let ingress = retrieved.spec.ingress.expect("Ingress config should exist"); + assert!(ingress.enabled); + assert_eq!(ingress.host, "sonarr.example.com"); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test Sonarr with HTTPRoute configuration for Gateway API +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_sonarr_with_http_route_config() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("sonarr-httproute"); + let sonarr = Sonarr { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrSpec { + http_route: Some(sonarr_operator::crds::sonarr::HTTPRouteConfig { + enabled: true, + gateway_ref: sonarr_operator::crds::sonarr::GatewayRef { + name: "my-gateway".to_string(), + namespace: Some("gateway-system".to_string()), + section_name: None, + }, + hostnames: vec!["sonarr.example.com".to_string()], + path: "/".to_string(), + path_type: "PathPrefix".to_string(), + labels: Default::default(), + annotations: Default::default(), + }), + ..Default::default() + }, + status: None, + }; + + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + let result = api + .patch(&name, &patch_params, &Patch::Apply(&sonarr)) + .await; + + assert!( + result.is_ok(), + "Failed to create Sonarr with HTTPRoute config: {:?}", + result.err() + ); + + // Verify HTTPRoute config + let retrieved = api.get(&name).await.expect("Failed to get Sonarr"); + let http_route = retrieved + .spec + .http_route + .expect("HTTPRoute config should exist"); + assert!(http_route.enabled); + assert_eq!(http_route.gateway_ref.name, "my-gateway"); + assert_eq!(http_route.hostnames, vec!["sonarr.example.com".to_string()]); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} diff --git a/tests/integration/tag_crd.rs b/tests/integration/tag_crd.rs new file mode 100644 index 0000000..58d9fb7 --- /dev/null +++ b/tests/integration/tag_crd.rs @@ -0,0 +1,239 @@ +//! Integration tests for the SonarrTag CRD + +use crate::common::*; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::api::{Api, DeleteParams, Patch, PatchParams}; +use sonarr_operator::crds::{SonarrInstanceRef, SonarrTag, SonarrTagSpec}; + +/// Test that the SonarrTag CRD is installed and established +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_sonarr_tag_crd_is_established() { + let client = test_client().await; + + assert!( + is_crd_established(&client, "sonarrtags.devopsarr.io").await, + "SonarrTag CRD is not established - run 'make install' first" + ); +} + +/// Test creating a SonarrTag resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_create_sonarr_tag() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("tag-test"); + let tag = SonarrTag { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrTagSpec { + label: "integration-test".to_string(), + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + }, + status: None, + }; + + // Create the resource + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + let result = api.patch(&name, &patch_params, &Patch::Apply(&tag)).await; + + assert!( + result.is_ok(), + "Failed to create SonarrTag: {:?}", + result.err() + ); + + // Verify it exists + let retrieved = api.get(&name).await; + assert!( + retrieved.is_ok(), + "Failed to get SonarrTag: {:?}", + retrieved.err() + ); + + let tag = retrieved.unwrap(); + assert_eq!(tag.spec.label, "integration-test"); + assert_eq!(tag.spec.sonarr_instance_ref.name, "test-sonarr"); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test updating a SonarrTag resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_update_sonarr_tag() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("tag-update"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + // Create initial resource + let tag = SonarrTag { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrTagSpec { + label: "original-label".to_string(), + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&tag)) + .await + .expect("Failed to create SonarrTag"); + + // Update the resource + let updated_tag = SonarrTag { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrTagSpec { + label: "updated-label".to_string(), + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&updated_tag)) + .await + .expect("Failed to update SonarrTag"); + + // Verify the update + let retrieved = api.get(&name).await.expect("Failed to get SonarrTag"); + assert_eq!(retrieved.spec.label, "updated-label"); + + // Cleanup + let _ = api.delete(&name, &DeleteParams::default()).await; +} + +/// Test deleting a SonarrTag resource +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_delete_sonarr_tag() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let name = unique_name("tag-delete"); + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + // Create resource + let tag = SonarrTag { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + ..Default::default() + }, + spec: SonarrTagSpec { + label: "to-be-deleted".to_string(), + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&tag)) + .await + .expect("Failed to create SonarrTag"); + + // Delete the resource + let delete_result = api.delete(&name, &DeleteParams::default()).await; + assert!( + delete_result.is_ok(), + "Failed to delete SonarrTag: {:?}", + delete_result.err() + ); + + // Verify it's deleted + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + let get_result = api.get(&name).await; + assert!(get_result.is_err(), "SonarrTag should have been deleted"); +} + +/// Test creating multiple tags +#[tokio::test] +#[ignore = "requires kubernetes cluster - run with: cargo test --test integration -- --ignored"] +async fn test_create_multiple_tags() { + let client = test_client().await; + ensure_test_namespace(&client) + .await + .expect("Failed to create test namespace"); + + let api: Api = Api::namespaced(client.clone(), TEST_NAMESPACE); + let patch_params = PatchParams::apply("sonarr-operator-test").force(); + + let tags = vec!["anime", "documentary", "kids"]; + let mut names = Vec::new(); + + for tag_label in &tags { + let name = unique_name(&format!("tag-multi-{}", tag_label)); + names.push(name.clone()); + + let tag = SonarrTag { + metadata: ObjectMeta { + name: Some(name.clone()), + namespace: Some(TEST_NAMESPACE.to_string()), + labels: Some([("test-group".to_string(), "multi-tag-test".to_string())].into()), + ..Default::default() + }, + spec: SonarrTagSpec { + label: tag_label.to_string(), + sonarr_instance_ref: SonarrInstanceRef { + name: "test-sonarr".to_string(), + namespace: None, + }, + }, + status: None, + }; + + api.patch(&name, &patch_params, &Patch::Apply(&tag)) + .await + .expect("Failed to create tag"); + } + + // List with label selector + let lp = kube::api::ListParams::default().labels("test-group=multi-tag-test"); + let list = api.list(&lp).await.expect("Failed to list tags"); + + assert!( + list.items.len() >= 3, + "Expected at least 3 tags, got {}", + list.items.len() + ); + + // Cleanup + for name in &names { + let _ = api.delete(name, &DeleteParams::default()).await; + } +}