From aeda999f73c58fbcd74467ba8039a79008bb111d Mon Sep 17 00:00:00 2001 From: Are Almaas Date: Tue, 19 May 2026 18:28:10 -0300 Subject: [PATCH 01/11] feat(dis-redis-operator): scaffold self-service Azure Managed Redis operator (RFC 0014) Adds a new operator that reconciles a Redis CR into Azure Managed Redis (Microsoft.Cache/redisEnterprise) via ASO. Mirrors dis-vault-operator (single-resource-per-CR, federated-identity-owned) and dis-pgsql-operator (private endpoint + private DNS + AKS VNet link). Entra-only data-plane access; no shared keys exposed. Shared privatelink.redis.azure.net zone (get-or-create, label-managed). Access policy assignment is deferred to a follow-up PR pending the ASO type. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/dis-redis-lint-test.yml | 61 +++ .github/workflows/dis-redis-release.yml | 83 ++++ .release-please-manifest.json | 1 + pr_description.md | 81 ++++ release-please-config.json | 4 + rfcs/0014-self-service-managed-redis.md | 258 ++++++++++++ .../0f974381-8734-4756-95d3-bc013825cdcd.json | 8 + services/dis-redis-operator/AGENTS.md | 93 +++++ services/dis-redis-operator/CHANGELOG.md | 1 + services/dis-redis-operator/Dockerfile | 34 ++ services/dis-redis-operator/Makefile | 361 +++++++++++++++++ services/dis-redis-operator/PROJECT | 11 + services/dis-redis-operator/README.md | 13 + .../api/v1alpha1/groupversion_info.go | 36 ++ .../api/v1alpha1/redis_types.go | 242 +++++++++++ .../api/v1alpha1/zz_generated.deepcopy.go | 214 ++++++++++ services/dis-redis-operator/cmd/main.go | 182 +++++++++ .../bases/redis.dis.altinn.cloud_redises.yaml | 189 +++++++++ .../config/crd/kustomization.yaml | 2 + .../config/default/deploy_vars_patch.yaml | 19 + .../config/default/kustomization.yaml | 16 + .../config/default/manager_metrics_patch.yaml | 4 + .../config/default/metrics_service.yaml | 18 + .../config/kind/applicationidentities.yaml | 7 + .../config/kind/kustomization.yaml | 14 + .../config/kind/manager_kind_patch.yaml | 33 ++ .../config/kind/serviceaccounts.yaml | 8 + .../config/manager/kustomization.yaml | 2 + .../config/manager/manager.yaml | 74 ++++ .../network-policy/allow-metrics-traffic.yaml | 23 ++ .../config/network-policy/kustomization.yaml | 2 + .../config/prometheus/kustomization.yaml | 2 + .../config/prometheus/monitor.yaml | 21 + .../config/rbac/kustomization.yaml | 9 + .../config/rbac/leader_election_role.yaml | 39 ++ .../rbac/leader_election_role_binding.yaml | 15 + .../config/rbac/metrics_auth_role.yaml | 17 + .../rbac/metrics_auth_role_binding.yaml | 12 + .../config/rbac/metrics_reader_role.yaml | 9 + .../dis-redis-operator/config/rbac/role.yaml | 88 ++++ .../config/rbac/role_binding.yaml | 15 + .../config/rbac/service_account.yaml | 8 + .../config/samples/kustomization.yaml | 3 + .../config/samples/redis_v1alpha1_redis.yaml | 17 + .../redis_v1alpha1_service_account_redis.yaml | 17 + services/dis-redis-operator/go.mod | 109 +++++ services/dis-redis-operator/go.sum | 381 ++++++++++++++++++ .../hack/boilerplate.go.txt | 15 + .../internal/config/config.go | 123 ++++++ .../internal/config/config_test.go | 81 ++++ .../internal/controller/redis_auth_watch.go | 52 +++ .../internal/controller/redis_controller.go | 295 ++++++++++++++ .../controller/redis_controller_network.go | 77 ++++ .../controller/redis_controller_role.go | 8 + .../controller/redis_controller_status.go | 178 ++++++++ .../controller/redis_controller_test.go | 15 + .../internal/controller/suite_test.go | 13 + .../internal/redis/builders.go | 324 +++++++++++++++ .../internal/redis/builders_test.go | 155 +++++++ .../internal/redis/identity.go | 181 +++++++++ .../internal/redis/identity_test.go | 169 ++++++++ .../internal/redis/labels.go | 10 + .../internal/redis/naming.go | 91 +++++ .../internal/redis/naming_test.go | 44 ++ .../internal/redis/status.go | 89 ++++ services/dis-redis-operator/version.txt | 1 + 66 files changed, 4777 insertions(+) create mode 100644 .github/workflows/dis-redis-lint-test.yml create mode 100644 .github/workflows/dis-redis-release.yml create mode 100644 pr_description.md create mode 100644 rfcs/0014-self-service-managed-redis.md create mode 100644 services/dis-redis-operator/.omc/sessions/0f974381-8734-4756-95d3-bc013825cdcd.json create mode 100644 services/dis-redis-operator/AGENTS.md create mode 100644 services/dis-redis-operator/CHANGELOG.md create mode 100644 services/dis-redis-operator/Dockerfile create mode 100644 services/dis-redis-operator/Makefile create mode 100644 services/dis-redis-operator/PROJECT create mode 100644 services/dis-redis-operator/README.md create mode 100644 services/dis-redis-operator/api/v1alpha1/groupversion_info.go create mode 100644 services/dis-redis-operator/api/v1alpha1/redis_types.go create mode 100644 services/dis-redis-operator/api/v1alpha1/zz_generated.deepcopy.go create mode 100644 services/dis-redis-operator/cmd/main.go create mode 100644 services/dis-redis-operator/config/crd/bases/redis.dis.altinn.cloud_redises.yaml create mode 100644 services/dis-redis-operator/config/crd/kustomization.yaml create mode 100644 services/dis-redis-operator/config/default/deploy_vars_patch.yaml create mode 100644 services/dis-redis-operator/config/default/kustomization.yaml create mode 100644 services/dis-redis-operator/config/default/manager_metrics_patch.yaml create mode 100644 services/dis-redis-operator/config/default/metrics_service.yaml create mode 100644 services/dis-redis-operator/config/kind/applicationidentities.yaml create mode 100644 services/dis-redis-operator/config/kind/kustomization.yaml create mode 100644 services/dis-redis-operator/config/kind/manager_kind_patch.yaml create mode 100644 services/dis-redis-operator/config/kind/serviceaccounts.yaml create mode 100644 services/dis-redis-operator/config/manager/kustomization.yaml create mode 100644 services/dis-redis-operator/config/manager/manager.yaml create mode 100644 services/dis-redis-operator/config/network-policy/allow-metrics-traffic.yaml create mode 100644 services/dis-redis-operator/config/network-policy/kustomization.yaml create mode 100644 services/dis-redis-operator/config/prometheus/kustomization.yaml create mode 100644 services/dis-redis-operator/config/prometheus/monitor.yaml create mode 100644 services/dis-redis-operator/config/rbac/kustomization.yaml create mode 100644 services/dis-redis-operator/config/rbac/leader_election_role.yaml create mode 100644 services/dis-redis-operator/config/rbac/leader_election_role_binding.yaml create mode 100644 services/dis-redis-operator/config/rbac/metrics_auth_role.yaml create mode 100644 services/dis-redis-operator/config/rbac/metrics_auth_role_binding.yaml create mode 100644 services/dis-redis-operator/config/rbac/metrics_reader_role.yaml create mode 100644 services/dis-redis-operator/config/rbac/role.yaml create mode 100644 services/dis-redis-operator/config/rbac/role_binding.yaml create mode 100644 services/dis-redis-operator/config/rbac/service_account.yaml create mode 100644 services/dis-redis-operator/config/samples/kustomization.yaml create mode 100644 services/dis-redis-operator/config/samples/redis_v1alpha1_redis.yaml create mode 100644 services/dis-redis-operator/config/samples/redis_v1alpha1_service_account_redis.yaml create mode 100644 services/dis-redis-operator/go.mod create mode 100644 services/dis-redis-operator/go.sum create mode 100644 services/dis-redis-operator/hack/boilerplate.go.txt create mode 100644 services/dis-redis-operator/internal/config/config.go create mode 100644 services/dis-redis-operator/internal/config/config_test.go create mode 100644 services/dis-redis-operator/internal/controller/redis_auth_watch.go create mode 100644 services/dis-redis-operator/internal/controller/redis_controller.go create mode 100644 services/dis-redis-operator/internal/controller/redis_controller_network.go create mode 100644 services/dis-redis-operator/internal/controller/redis_controller_role.go create mode 100644 services/dis-redis-operator/internal/controller/redis_controller_status.go create mode 100644 services/dis-redis-operator/internal/controller/redis_controller_test.go create mode 100644 services/dis-redis-operator/internal/controller/suite_test.go create mode 100644 services/dis-redis-operator/internal/redis/builders.go create mode 100644 services/dis-redis-operator/internal/redis/builders_test.go create mode 100644 services/dis-redis-operator/internal/redis/identity.go create mode 100644 services/dis-redis-operator/internal/redis/identity_test.go create mode 100644 services/dis-redis-operator/internal/redis/labels.go create mode 100644 services/dis-redis-operator/internal/redis/naming.go create mode 100644 services/dis-redis-operator/internal/redis/naming_test.go create mode 100644 services/dis-redis-operator/internal/redis/status.go create mode 100644 services/dis-redis-operator/version.txt diff --git a/.github/workflows/dis-redis-lint-test.yml b/.github/workflows/dis-redis-lint-test.yml new file mode 100644 index 000000000..b5abb5c89 --- /dev/null +++ b/.github/workflows/dis-redis-lint-test.yml @@ -0,0 +1,61 @@ +name: Dis Redis Lint and Test + +on: + push: + branches: + - main + paths: + - services/dis-redis-operator/** + - .github/workflows/dis-redis-lint-test.yml + pull_request: + branches: + - main + paths: + - services/dis-redis-operator/** + - .github/workflows/dis-redis-lint-test.yml + +permissions: + contents: read + +jobs: + lint: + name: Run linter on Ubuntu + runs-on: ubuntu-latest + defaults: + run: + working-directory: services/dis-redis-operator + steps: + - name: Clone the code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: "services/dis-redis-operator/go.mod" + cache-dependency-path: "services/dis-redis-operator/go.sum" + + - name: Run linter + uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0 + with: + version: v2.11.4 + working-directory: services/dis-redis-operator + test: + name: Run tests on Ubuntu + runs-on: ubuntu-latest + defaults: + run: + working-directory: services/dis-redis-operator + steps: + - name: Clone the code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: "services/dis-redis-operator/go.mod" + cache-dependency-path: "services/dis-redis-operator/go.sum" + + - name: Running Tests + run: | + go mod tidy + make test diff --git a/.github/workflows/dis-redis-release.yml b/.github/workflows/dis-redis-release.yml new file mode 100644 index 000000000..6741b5674 --- /dev/null +++ b/.github/workflows/dis-redis-release.yml @@ -0,0 +1,83 @@ +name: Scan/Release Dis Redis Operator Image and Build Kustomize OCI Artifact + +env: + FLUX_ARTIFACT_NAME: dis/kustomize/dis-redis-operator + +on: + pull_request: + branches: + - main + paths: + - .github/workflows/dis-redis-release.yml + - services/dis-redis-operator/** + - .github/workflows/reusable-image-scan-and-release-ghcr.yml + push: + branches: + - main + paths: + - .github/workflows/dis-redis-release.yml + - services/dis-redis-operator/** + - .github/workflows/reusable-image-scan-and-release-ghcr.yml + tags: + - "dis-redis-v*" + workflow_dispatch: {} + +permissions: + contents: read + packages: write + id-token: write + +jobs: + build-and-release-image: + name: Build, scan and release + uses: ./.github/workflows/reusable-image-scan-and-release-ghcr.yml + with: + release_latest: true + image_name: dis-redis-operator + tag_prefix: dis-redis-v + platforms: "linux/amd64" + workdir: ./services/dis-redis-operator + build-release-flux-oci-latest: + name: Build latest from main + if: github.ref == 'refs/heads/main' + defaults: + run: + working-directory: ./services/dis-redis-operator/config + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Push latest flux oci image from main + uses: ./actions/flux/build-push-image + with: + workdir: ./services/dis-redis-operator/config + image_name: ${{ env.FLUX_ARTIFACT_NAME }} + tag: latest + azure_subscription_id: ${{ secrets.AZURE_ALTINNACR_SUBSCRIPTION_ID }} + azure_app_id: ${{ secrets.AZURE_ALTINNACR_APP_ID }} + azure_tenant_id: ${{ secrets.AZURE_TENANT_ID }} + build-release-flux-oci-release: + name: Build release from tag + if: startsWith(github.ref, 'refs/tags/dis-redis-v') + environment: flux-release + defaults: + run: + working-directory: ./services/dis-redis-operator/config + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Setup vars + id: vars + run: | + tag="${GITHUB_REF/refs\/tags\/dis-redis-/}" + echo "tag=${tag}" >> ${GITHUB_OUTPUT} + - name: Push flux oci image from release tag + uses: ./actions/flux/build-push-image + with: + workdir: ./services/dis-redis-operator/config + image_name: ${{ env.FLUX_ARTIFACT_NAME }} + tag: ${{ steps.vars.outputs.tag }} + azure_subscription_id: ${{ secrets.AZURE_ALTINNACR_SUBSCRIPTION_ID }} + azure_app_id: ${{ secrets.AZURE_ALTINNACR_APP_ID }} + azure_tenant_id: ${{ secrets.AZURE_TENANT_ID }} diff --git a/.release-please-manifest.json b/.release-please-manifest.json index eef57effb..2b0fed659 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -3,6 +3,7 @@ "services/dis-identity-operator": "0.2.1", "services/dis-apim-operator": "1.0.0", "services/dis-vault-operator": "1.4.3", + "services/dis-redis-operator": "0.1.0", "services/lakmus": "1.1.0", "infrastructure/images/azure-devops-agent": "1.2.7", "infrastructure/images/gh-runner": "0.6.2", diff --git a/pr_description.md b/pr_description.md new file mode 100644 index 000000000..d7afc2577 --- /dev/null +++ b/pr_description.md @@ -0,0 +1,81 @@ +# Add dis-redis-operator (RFC 0014) — self-service Azure Managed Redis + +This PR scaffolds a new operator, `dis-redis-operator`, that reconciles a `Redis` CR into Azure Managed Redis (`Microsoft.Cache/redisEnterprise`) via Azure Service Operator. It mirrors the proven patterns from `dis-vault-operator` (single-resource-per-CR, federated-identity-owned) and `dis-pgsql-operator` (private endpoint + shared private DNS + AKS VNet link). + +See [RFC 0014](rfcs/0014-self-service-managed-redis.md) for the full design. + +## Feature Behavior (BDD) + +**Given** a `Redis` custom resource in the team namespace that references a ready `ApplicationIdentity` via `spec.identityRef`, +**When** the operator reconciles the CR, +**Then** it computes a deterministic Azure cluster name from `namespace + name + environment`, +**And** it creates ASO `RedisEnterprise` (cluster) and `RedisEnterpriseDatabase` resources with `accessKeysAuthentication=Disabled`, TLS-only client protocol by default, and port 10000, +**And** it creates an ASO `PrivateEndpoint` targeting the cluster in the configured AKS data subnet, +**And** it get-or-creates the shared `privatelink.redis.azure.net` private DNS zone and the AKS VNet link to it (label-managed, not owner-referenced to any single CR), +**And** it publishes status conditions `IdentityReady`, `ClusterReady`, `DatabaseReady`, `PrivateEndpointReady`, `PrivateDNSReady`, `AccessPolicyReady`, and an aggregated `Ready`, +**And** it populates `status.azureName`, `status.hostName`, `status.port`, `status.clusterResourceId`, `status.databaseResourceId`, and `status.ownerPrincipalId`. + +**Given** the referenced identity is not yet ready (missing `status.principalId` or `Ready=True`), +**When** the operator reconciles, +**Then** it sets `IdentityReady=False` with reason `IdentityNotReady`, leaves Azure resources untouched, and requeues after 5 seconds. + +**Given** the `Redis` CR specifies `spec.serviceAccountRef` instead of `spec.identityRef`, +**When** the operator reconciles, +**Then** it resolves the principal from the workload-identity annotations (`azure.workload.identity/client-id` and `dis.altinn.cloud/principal-id`) on the referenced `ServiceAccount`. + +**Given** the `Redis` CR is deleted, +**When** the operator observes the deletion, +**Then** Kubernetes garbage collection cascades deletion to the owner-referenced cluster, database, and private endpoint resources, while the shared DNS zone and VNet link remain (they outlive any single CR). + +> Note: `AccessPolicyAssignment` reconciliation is deferred to a follow-up PR (the upstream ASO type is not yet available in v2.17.0). The `AccessPolicyReady` condition reports `Unknown` / `Pending` until then. + +## ASCII Diagram + +``` + ┌──────────────────────────────┐ + │ Team namespace │ + │ │ + │ Redis CR ──ref──> AppIdent. │ + │ │ │ │ + │ │ (controller │ │ + │ │ resolves │ │ + │ │ principalId) │ │ + └─────┼──────────────────┼─────┘ + │ │ + ▼ │ + ┌────────────────────────────┐ │ + │ dis-redis-operator │ │ + └─────┬───────────┬──────────┘ │ + │ │ │ + owns: ┌────▼───┐ ┌────▼──────┐ │ + │ ASO │ │ ASO │ │ + │ Cluster│ │ Database │ │ + └────┬───┘ └─────┬─────┘ │ + │ │ │ + ┌─────────▼──┐ ┌────▼─────────┐ │ + │ ASO │ │ (future PR) │ │ + │ Private │ │ Access │ │ + │ Endpoint │ │ Policy │ │ + └─────┬──────┘ │ Assignment │ │ + │ └──────────────┘ │ + │ │ + ▼ │ + ┌─────────────────────────────────────┐ │ + │ shared (label-managed, namespace- │ │ + │ scoped, not owner-ref'd to any CR): │ │ + │ - PrivateDnsZone: │ │ + │ privatelink.redis.azure.net │ │ + │ - PrivateDnsZonesVirtualNetworkLink │ │ + │ → AKS VNet │ │ + └─────────────────┬───────────────────┘ │ + │ │ + ▼ │ + ┌──────────────────────┐ │ + │ Azure subscription │ │ + │ ┌────────────────┐ │ ┌──────────▼───────────┐ + │ │ RedisEnterprise│◀──┼───│ Workload pod │ + │ │ + Database │ │ │ (TLS 10000, Entra │ + │ │ + Priv. EP │ │ │ token via federated │ + │ └────────────────┘ │ │ workload identity) │ + └──────────────────────┘ └──────────────────────┘ +``` diff --git a/release-please-config.json b/release-please-config.json index 3953ee5c5..1dedca2e4 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -16,6 +16,10 @@ "release-type": "simple", "component": "dis-vault" }, + "services/dis-redis-operator": { + "release-type": "simple", + "component": "dis-redis" + }, "services/lakmus": { "release-type": "simple", "component": "lakmus" diff --git a/rfcs/0014-self-service-managed-redis.md b/rfcs/0014-self-service-managed-redis.md new file mode 100644 index 000000000..aac2ae3aa --- /dev/null +++ b/rfcs/0014-self-service-managed-redis.md @@ -0,0 +1,258 @@ +- Feature Name: self_service_managed_redis +- Start Date: 2026-05-19 +- RFC PR: [altinn/altinn-platform#0014](https://github.com/Altinn/altinn-platform/pull/0014) +- Github Issue: [altinn/altinn-platform#0014](https://github.com/Altinn/altinn-platform/issues/0014) +- Product/Category: Container Runtime +- State: **REVIEW** (possible states are: **REVIEW**, **ACCEPTED** and **REJECTED**) + +# Summary + +This RFC proposes a new Kubernetes operator, `dis-redis-operator`, that provides self-service Azure Managed Redis (`Microsoft.Cache/redisEnterprise`) provisioning for DIS applications. App teams declare a `Redis` custom resource in Kubernetes, and the operator reconciles it into Azure resources through Azure Service Operator (ASO). The proposal is opinionated: one Redis Enterprise cluster + one database per CR, Entra-only data-plane authentication via federated identity, and private-endpoint-only network access with a shared private DNS zone linked to the AKS VNet. No shared access keys are exposed to workloads. + +# Motivation + +Caching is a primary DIS use case but today there is no first-class self-service way to provision a Redis instance. Teams must: +- Manually create `Microsoft.Cache/redisEnterprise` clusters and databases in the Azure portal or in side-channel infrastructure code. +- Manually wire up private endpoints and private DNS records to make the cache reachable from AKS. +- Manually grant data-plane access to their workload identity (or, worse, distribute shared keys). + +This leads to: +- Platform-team bottlenecks. +- Inconsistent security defaults (key sprawl, public network access, missing private DNS). +- Slow lead time for app onboarding. +- Drift between intended and actual cloud configuration. + +We want the same self-service, declarative pattern we already use for `dis-vault-operator` (RFC 0009) and `dis-pgsql-operator` (RFC 0006): +- App teams request capability through a CR. +- Operator enforces platform defaults and guardrails. +- ASO performs Azure resource provisioning. +- Status and readiness are visible in Kubernetes. + +# Guide-level explanation + +An app team that needs Redis creates a `Redis` resource in its namespace and references its owning `ApplicationIdentity`: + +```yaml +apiVersion: redis.dis.altinn.cloud/v1alpha1 +kind: Redis +metadata: + name: my-app-cache +spec: + identityRef: + name: my-app-identity + sku: Balanced_B0 + highAvailability: true + clientProtocol: Encrypted + evictionPolicy: NoEviction + tags: + app: my-app + env: prod +``` + +The operator then: +1. Resolves the referenced `ApplicationIdentity`. +2. Waits until identity is ready and has a `principalId`. +3. Creates/reconciles a `RedisEnterprise` cluster + `RedisEnterpriseDatabase` via ASO. +4. Creates/reconciles a private endpoint targeting the cluster in the configured AKS data subnet. +5. Get-or-creates the shared `privatelink.redis.azure.net` private DNS zone and the AKS VNet link to it. +6. Creates a `PrivateDnsZoneGroup` binding the private endpoint A-record into the shared zone. +7. Creates/reconciles a `RedisEnterpriseDatabaseAccessPolicyAssignment` granting the resolved identity data-plane access using the built-in `default` access policy (full read/write). +8. Publishes readiness and resulting values (cluster + database ARM IDs, host name, port) in `status`. + +Application code connects to the cache over TLS on port 10000, using an Entra-aware Redis client (for example `StackExchange.Redis` with an `Azure.Identity.TokenCredential`). No shared keys, no Kubernetes Secret distribution. + +## Security and network defaults in v1 + +- **Auth**: Entra-only via federated identity. The `RedisEnterpriseDatabase` spec sets `accessKeysAuthentication=Disabled`. Data-plane access is granted via `RedisEnterpriseDatabaseAccessPolicyAssignment` referencing the resolved Entra object ID. +- **Network**: Private endpoint only. The operator owns the private endpoint, the private DNS zone group binding, and (idempotently, get-or-create) the shared `privatelink.redis.azure.net` zone and AKS VNet link. +- **Transport**: `clientProtocol=Encrypted` is the default; `Plaintext` requires explicit opt-in. + +# Reference-level explanation + +## CRD contract + +### Potential Spec (v1alpha1) +- `identityRef.name` or `serviceAccountRef.name` (exactly one required): same-namespace reference to the owning identity. `identityRef` points to an `ApplicationIdentity` from `application.dis.altinn.cloud/v1alpha1`; `serviceAccountRef` points to a `ServiceAccount` annotated with `azure.workload.identity/client-id` and `dis.altinn.cloud/principal-id`. +- `sku` (optional): one of `Balanced_B0|Balanced_B1|Balanced_B3|Balanced_B5|Balanced_B10|MemoryOptimized_M10|MemoryOptimized_M20`. Default `Balanced_B0`. +- `highAvailability` (optional `bool`, default `true`): when `true`, deploy across multiple availability zones. +- `version` (optional `string`): Redis version (e.g. `7`, `7.4`). Defaults to ASO/Azure default. +- `clientProtocol` (optional): `Encrypted|Plaintext`. Default `Encrypted`. +- `evictionPolicy` (optional): one of `AllKeysLFU|AllKeysLRU|AllKeysRandom|VolatileLFU|VolatileLRU|VolatileRandom|VolatileTTL|NoEviction`. Default `NoEviction`. +- `modules` (optional `[]RedisModule`): enable `RedisJSON`, `RediSearch`, `RedisTimeSeries`, `RedisBloom`. +- `persistence` (optional): AOF / RDB configuration. Default no persistence. +- `tags` (optional): additional Azure tags. + +### Potential Status (v1alpha1) +- `conditions[]`: + - `Ready` + - `IdentityReady` + - `ClusterReady` + - `DatabaseReady` + - `PrivateEndpointReady` + - `PrivateDNSReady` + - `AccessPolicyReady` +- `azureName` — computed deterministic cluster name +- `clusterResourceId` — ARM ID of the `redisEnterprise` cluster +- `databaseResourceId` — ARM ID of the `redisEnterprise/databases/default` resource +- `hostName` — e.g. `myredis.norwayeast.redis.azure.net` +- `port` — `10000` by default +- `ownerPrincipalId` — resolved owner principal ID +- `accessPolicyAssignmentName` — name of the managed access policy assignment +- `observedGeneration` — last reconciled generation + +## ASO resources and mapping + +### Redis Enterprise cluster +- Resource: `cache.azure.com/v1api20250401.RedisEnterprise` +- Key fields set by operator: + - `sku.name` from `spec.sku` + - `zones` from `spec.highAvailability` (multi-zone when true, otherwise no zones) + - `properties.minimumTlsVersion=1.2` + - `tags` merged with `spec.tags` + +### Redis Enterprise database +- Resource: `cache.azure.com/v1api20250401.RedisEnterpriseDatabase` +- Owner: the `RedisEnterprise` cluster +- Key fields: + - `clientProtocol` from `spec.clientProtocol` + - `evictionPolicy` from `spec.evictionPolicy` + - `accessKeysAuthentication=Disabled` + - `port=10000` + - `modules` from `spec.modules` + - `persistence` from `spec.persistence` + +### Access policy assignment +- Resource: `cache.azure.com/v1api20250401.RedisEnterpriseDatabaseAccessPolicyAssignment` +- Owner: the `RedisEnterpriseDatabase` +- Key fields: + - `accessPolicyName="default"` + - `user.objectId` from the resolved identity's `principalId` + +### Networking +- `network.azure.com/v1api20240601.PrivateEndpoint` per `Redis` CR. Target the cluster, land in the configured AKS data subnet. +- `network.azure.com/v1api20240601.PrivateDnsZone` named `privatelink.redis.azure.net`. **Single shared zone** (see below), get-or-create by name, label-managed (no owner reference to any `Redis` CR). +- `network.azure.com/v1api20240601.PrivateDnsZonesVirtualNetworkLink` linking the shared zone to the AKS VNet. Single, get-or-create, label-managed. +- `network.azure.com/v1api20240601.PrivateDnsZoneGroup` per `Redis` CR. Owner-ref to the `Redis` CR so it cascades on delete. + +#### Why one shared DNS zone (not per-CR) + +`dis-pgsql-operator` uses a per-instance private DNS zone (`{db-name}.private.postgres.database.azure.com`), which works because PG Flexible Server supports BYO-DNS. Azure Managed Redis does not: the private endpoint CNAME chain hard-codes `..privatelink.redis.azure.net` as the target, so the zone literally has to be named `privatelink.redis.azure.net` for resolution to work. Splitting it per-CR would break DNS resolution. + +The operator therefore uses an idempotent get-or-create for the zone + VNet link, identified by a `redis.dis.altinn.cloud/managed-by=dis-redis-operator` label rather than an owner-ref. The per-instance `privateEndpoint` and `privateDnsZoneGroup` keep their owner-refs to the `Redis` CR, so deletion cascades correctly for those. + +## Reconciliation flow + +```mermaid +sequenceDiagram +participant dev as App Team +participant kapi as Kubernetes API +participant redisop as dis-redis-operator +participant aso as Azure Service Operator +participant azure as Azure Redis/Network/RBAC + +dev->>kapi: Create/Update Redis CR +kapi->>redisop: Reconcile Redis +redisop->>kapi: Read ApplicationIdentity +alt Identity not ready + redisop->>kapi: Set IdentityReady=False, requeue 5s +else Identity ready + redisop->>kapi: Create/Update RedisEnterprise + RedisEnterpriseDatabase + redisop->>kapi: Create/Update PrivateEndpoint + PrivateDnsZoneGroup + redisop->>kapi: Get-or-create shared PrivateDnsZone + VNet link + kapi->>aso: Reconcile ASO resources + aso->>azure: Provision/Update cluster, database, networking, access policy + redisop->>kapi: Create/Update RedisEnterpriseDatabaseAccessPolicyAssignment + redisop->>kapi: Aggregate Ready, project status (hostName, port, ARM IDs) +end +``` + +## Naming strategy + +Redis Enterprise cluster names are DNS-label scoped and must be unique within the region. The operator generates deterministic Azure names from namespace + name + environment using the same hashing approach as `dis-vault-operator` and `dis-pgsql-operator`: +- Avoids collisions across teams. +- Stable across reconciles. +- Removes manual naming burden for app teams. + +## Operator's configuration + +Required operator env: +- `DISREDIS_AZURE_SUBSCRIPTION_ID` +- `DISREDIS_RESOURCE_GROUP` +- `DISREDIS_AZURE_TENANT_ID` +- `DISREDIS_LOCATION` +- `DISREDIS_ENV` +- `DISREDIS_AKS_SUBNET_IDS` (comma-separated; the first entry is the subnet that private endpoints land in) +- `DISREDIS_AKS_VNET_ID` (the AKS VNet ARM ID, used for the shared DNS zone VNet link) +- `DISREDIS_DNS_ZONE_RESOURCE_GROUP` (resource group where the shared `privatelink.redis.azure.net` zone lives — typically the operator's own RG) + +Startup validation fails fast on missing/invalid required values. + +## Compatibility and migration + +- No direct migration impact for existing workloads because this introduces a new CRD/operator path. +- Teams adopting this model move from manual Azure portal / Terraform-managed Redis caches to declarative `Redis` resources. +- ASO dependency must be bumped to v2.18.0+ for this operator to pick up `RedisEnterpriseDatabaseAccessPolicyAssignment` in `v1api20250401`. `dis-vault-operator` and `dis-pgsql-operator` can stay on their current ASO version because each operator is its own Go module. + +# Drawbacks + +- Adds another platform operator to maintain. +- Depends on ASO API versions and behavior. The newly required `v1api20250401` cache API needs ASO v2.18.0+. +- Strong defaults can require exceptions for some advanced workloads (multi-DB, shared cluster). +- Shared private DNS zone means a misbehaving Redis CR can in principle pollute the zone — mitigated by the per-CR `PrivateDnsZoneGroup` being narrowly scoped and owner-ref bound. + +# Rationale and alternatives + +## Chosen design rationale + +The operator + ASO model is consistent with DIS direction: +- Declarative self-service via Kubernetes. +- Reconciliation loop for drift correction. +- Reuse of dis tooling/resources, e.g. `ApplicationIdentity` and `dis-identity-operator` federated credentials. + +The Entra-only access policy plus private endpoint network model gives us a default-deny posture: no keys to rotate or distribute, no public network surface. + +## Alternatives considered + +### 1. Helm-chart-only (no operator) +Does not track Azure state, no drift correction, no status surface, no shared DNS zone management. Rejected. + +### 2. Legacy `Microsoft.Cache/redis` +Weaker Entra integration, no `AccessPolicyAssignment` API, weaker network model (only Premium SKU supports VNet injection). Rejected in favor of the newer `Microsoft.Cache/redisEnterprise` (Azure Managed Redis) offering, which supports private link across all SKUs. + +### 3. Key-based authentication with ESO SecretStore +Shared-secret blast radius, rotation pain, leaks via image/log/secret sprawl. Explicitly rejected per the Entra-only decision. + +## Impact of not doing this + +We keep fragmented Redis provisioning workflows and continue to miss the self-service goal for cached data infrastructure. + +# Prior art + +Dis related: + +- [RFC 0006 - self_service_postgresql_database](https://github.com/Altinn/altinn-platform/blob/main/rfcs/0006-serlf-service-psql.md) — operator + ASO + private endpoint pattern. +- [RFC 0009 - self_service_key_vault](https://github.com/Altinn/altinn-platform/blob/main/rfcs/0009-self-service-key-vault.md) — single-resource-per-CR + federated-identity-owned pattern. +- `dis-pgsql-operator` implementation patterns: + - private DNS zone + VNet link reconciliation (`internal/controller/database_controller_dns.go`). +- `dis-vault-operator` implementation patterns: + - identity resolver (`internal/vault/identity.go`). + - watch mapping (`internal/controller/vault_auth_watch.go`). + - ASO readiness gating, condition aggregation. +- `dis-identity-operator` as source of identity lifecycle and status fields. +- Azure Service Operator as the Azure control plane integration. + +# Unresolved questions + +- Multi-DB-per-cluster (Shared mode like `dis-pgsql`) — future RFC. +- Backup / restore semantics — out of scope v1. +- Per-CR private DNS zone (vault-style) vs shared zone — decided shared (see "Why one shared DNS zone"), document the trade-off for future revisitation. +- Group access (`groupObjectId`) — easy follow-up, mirrors `dis-vault-operator`'s group role assignment. + +# Future possibilities + +- Multi-database / shared-cluster mode for cost-sensitive workloads. +- Backup / restore configuration (export to storage account, point-in-time restore). +- Group access via `groupObjectId` for shared team access. +- ConfigMap projection of `hostName`/`port` for vault-style consumption ergonomics. +- Per-CR network override policy (different subnets, additional VNet links) with guardrails. +- Cluster autoscaling / capacity adjustment hints in status. diff --git a/services/dis-redis-operator/.omc/sessions/0f974381-8734-4756-95d3-bc013825cdcd.json b/services/dis-redis-operator/.omc/sessions/0f974381-8734-4756-95d3-bc013825cdcd.json new file mode 100644 index 000000000..9045cba1e --- /dev/null +++ b/services/dis-redis-operator/.omc/sessions/0f974381-8734-4756-95d3-bc013825cdcd.json @@ -0,0 +1,8 @@ +{ + "session_id": "0f974381-8734-4756-95d3-bc013825cdcd", + "ended_at": "2026-05-19T21:24:55.756Z", + "reason": "other", + "agents_spawned": 0, + "agents_completed": 0, + "modes_used": [] +} \ No newline at end of file diff --git a/services/dis-redis-operator/AGENTS.md b/services/dis-redis-operator/AGENTS.md new file mode 100644 index 000000000..eab249a0a --- /dev/null +++ b/services/dis-redis-operator/AGENTS.md @@ -0,0 +1,93 @@ +# AGENTS.md + +## Project goals +- Provide self-service Azure Managed Redis (`Microsoft.Cache/redisEnterprise`) provisioning for app owners via the `Redis` CR applied through GitOps. +- Reconcile `Redis` CRs into a Redis Enterprise cluster + database with private endpoint networking and Entra-only data-plane access. +- Ensure secure defaults: private endpoint only, TLS enforced, access keys disabled, deterministic naming. +- Keep operations safe and observable: idempotent reconciles, clear status/conditions, and predictable behavior. + +## RFC + +- RFC reference: [RFC 0014 - Self-service Managed Redis](https://github.com/Altinn/altinn-platform/blob/main/rfcs/0014-self-service-managed-redis.md). + +## Quick start +- Install deps / tools: follow `make help` and the `Makefile` toolchain (Go, controller-gen, kustomize). +- List targets: `make help` + +## Local workspace (dev only) +- There is no predefined module pairing yet for `dis-redis-operator`. +- Add workspace pairings only after the module dependency pattern is established. +- If you create a local `go.work`/`go.work.sum`, keep them uncommitted (dev-only artifacts). +- Avoid running `go work sync` on shared branches; it can update other modules' `go.mod`/`go.sum`. + +## Common commands +- Format: `make fmt-cache` +- Lint: `make lint-cache` +- Generate code: `make generate-cache` +- Generate manifests (CRDs/RBAC/webhooks): `make manifests-cache` +- Unit tests: `make test-cache` +- Default test entrypoint: use `make test` / `make test-cache`, not raw `go test`, unless you are isolating a narrow debugging case. +- Build manager binary: `make build-cache` +- Vulnerability scan: `make govulncheck-cache` + +## Required verification for code changes +If you modify any files under: +- `api/**`, `cmd/**`, `internal/**`, `test/**`, `config/**` + +You MUST run these commands before producing a final answer/patch: +1. `make fmt-cache` +2. `make generate-cache` +3. `make manifests-cache` (required if `api/**` or `config/**` changed) +4. `make test-ci-cache` +5. `make lint-cache` + +You can run all these by running `make run-checks-ci-cache` + +Use the Make targets above as the primary verification path. +Do not substitute ad hoc `go test` commands for the required Make targets. +Direct `go test` is acceptable only for narrow debugging during development, and does not replace final verification. + +In the final response, include the command(s) you ran and whether they passed. +If you cannot run them, you MUST say so explicitly and explain why. + +## Non-negotiable +Do not claim checks passed unless you actually ran them. + +## CRD/API changes +If you touch `api/**`: +- Ensure `make manifests-cache` is run (CRDs/RBAC/webhooks updated). +- If sample YAML exists (often under `config/samples/**`), try to update it to match the new schema. +- Avoid breaking changes unless explicitly intended. + +## Running and deploying +- Run in Kind (local): `make test-e2e` +- Install CRDs: `make install-cache` +- Undeploy: `make undeploy-cache` +- Uninstall CRDs: `make uninstall-cache` + +## Git expectations +Before opening a PR, ensure: +- Never run git push by yourself +- Always suggest to create a new branch in case we are working on main by mistake +- Do not use `git add -f` / `git add --force` when preparing PRs. If a file is ignored, treat it as local-only unless the user explicitly asks to stage it. + +## PR description file +- When working on a branch and making changes, always create or update `pr_description.md` in the repository root. +- `pr_description.md` must contain: + 1. `Feature Behavior (BDD)` + 2. `ASCII Diagram` +- The BDD section must be based on implemented behavior and use explicit BDD keywords highlighted in text: + - `**Given**` + - `**When**` + - `**Then**` + - `**And**` +- Do not add extra sections (for example test-delta summaries) unless explicitly requested by the user. +- Keep this file in sync as the branch evolves so it is ready to use in the PR. + +## Code organization +- `internal/controller` should only contain high-level controller duties and orchestration. +- Domain logic should live in dedicated packages (for example `internal/redis`). +- Role-/access-policy reconciliation helpers in `internal/controller` should live in `redis_controller_role.go`. +- Private endpoint / DNS reconciliation helpers in `internal/controller` should live in `redis_controller_network.go`. +- Tests for `internal/controller` code should live in `redis_controller_test.go` (Ginkgo) for high-level behavior coverage. +- All remaining tests (packages that are not controller packages) should follow standard Go unit test conventions (`x.go` + `x_test.go`). diff --git a/services/dis-redis-operator/CHANGELOG.md b/services/dis-redis-operator/CHANGELOG.md new file mode 100644 index 000000000..825c32f0d --- /dev/null +++ b/services/dis-redis-operator/CHANGELOG.md @@ -0,0 +1 @@ +# Changelog diff --git a/services/dis-redis-operator/Dockerfile b/services/dis-redis-operator/Dockerfile new file mode 100644 index 000000000..76f9201bf --- /dev/null +++ b/services/dis-redis-operator/Dockerfile @@ -0,0 +1,34 @@ +# Build the manager binary +FROM golang:1.26.3 AS builder +ARG TARGETOS +ARG TARGETARCH + +WORKDIR /workspace +# Copy the Go Modules manifests +COPY go.mod go.mod +COPY go.sum go.sum +# cache deps before building and copying source so that we don't need to re-download as much +# and so that source changes don't invalidate our downloaded layer +RUN go mod download + +# Copy the Go source +COPY cmd/ cmd/ +COPY api/ api/ +COPY internal/ internal/ + +# Build +# the GOARCH has no default value to allow the binary to be built according to the host where the command +# was called. For example, if we call make docker-build in a local env which has the Apple Silicon M1 SO +# the docker BUILDPLATFORM arg will be linux/arm64 when for Apple x86 it will be linux/amd64. Therefore, +# by leaving it empty we can ensure that the container and binary shipped on it will have the same platform. +RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} \ + go build -trimpath -ldflags="-s -w" -o manager ./cmd/main.go + +# Use distroless as minimal base image to package the manager binary +# Refer to https://github.com/GoogleContainerTools/distroless for more details +FROM gcr.io/distroless/static:nonroot@sha256:e3f945647ffb95b5839c07038d64f9811adf17308b9121d8a2b87b6a22a80a39 +WORKDIR / +COPY --from=builder /workspace/manager . +USER 65532:65532 + +ENTRYPOINT ["/manager"] diff --git a/services/dis-redis-operator/Makefile b/services/dis-redis-operator/Makefile new file mode 100644 index 000000000..9ddb16fed --- /dev/null +++ b/services/dis-redis-operator/Makefile @@ -0,0 +1,361 @@ +# Image URL to use for build/deploy targets. +# Keep the localhost/ prefix: in local Kind flows we load this image directly, +# and this avoids accidental registry lookups when the image name is overridden. +IMG ?= localhost/controller:latest +KIND_IMAGE_ARCHIVE := bin/dis-redis-operator_image.tar + +# Local Go cache settings for CI check runs +CACHE_DIR ?= $(CURDIR)/.cache +GOCACHE ?= $(CACHE_DIR)/go-build +WORKSPACE_ROOT ?= $(abspath $(CURDIR)/../..) +CERT_MANAGER_INSTALL_SKIP ?= true + +# Get the currently used golang install path (in GOPATH/bin, unless GOBIN is set) +ifeq (,$(shell go env GOBIN)) +GOBIN=$(shell go env GOPATH)/bin +else +GOBIN=$(shell go env GOBIN) +endif + +# CONTAINER_TOOL defines the container tool to be used for building images. +# Be aware that the target commands are only tested with Docker which is +# scaffolded by default. However, you might want to replace it to use other +# tools. (i.e. podman) +CONTAINER_TOOL ?= podman + +# Setting SHELL to bash allows bash commands to be executed by recipes. +# Options are set to exit when a recipe line exits non-zero or a piped command fails. +SHELL = /usr/bin/env bash -o pipefail +.SHELLFLAGS = -ec + +.PHONY: all +all: build + +##@ General + +.PHONY: help +help: ## Display this help. + @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) + +.PHONY: workspace +workspace: ## Create local go.work for dev across dis-redis-operator and dis-identity-operator (not committed). + @cd "$(WORKSPACE_ROOT)" && \ + if [ -f go.work ]; then \ + echo "go.work already exists; updating modules"; \ + go work use ./services/dis-redis-operator ./services/dis-identity-operator; \ + else \ + go work init ./services/dis-redis-operator ./services/dis-identity-operator; \ + fi + @echo "Local workspace at $(WORKSPACE_ROOT)/go.work (ignored by git)." + +.PHONY: workspace-clean +workspace-clean: ## Remove local go.work/go.work.sum (dev only). + @rm -f "$(WORKSPACE_ROOT)/go.work" "$(WORKSPACE_ROOT)/go.work.sum" + +.PHONY: setup-local-env +setup-local-env: ## Bootstrap local dev environment (shared by Codex/Claude/Gemini). + $(MAKE) workspace + $(MAKE) cache-setup + $(MAKE) kustomize + $(MAKE) controller-gen + $(MAKE) golangci-lint + $(MAKE) setup-envtest + @mkdir -p "$(CACHE_DIR)/golangci-lint" + @echo "Local environment bootstrap completed." + +.PHONY: clean +clean: workspace-clean tidy verify-deps lint test ## Clean local dev artifacts before pushing. + +.PHONY: tidy +tidy: ## Run go mod tidy. + go mod tidy + +.PHONY: verify-deps +verify-deps: ## Verify go.mod/go.sum resolve from a clean module cache. + @tmp_modcache="$$(mktemp -d)"; \ + trap 'rm -rf "$$tmp_modcache"' EXIT INT TERM; \ + echo "Verifying module pins with clean module cache: $$tmp_modcache"; \ + GOWORK=off GOFLAGS=-mod=readonly GOMODCACHE="$$tmp_modcache" go mod download + +##@ Cache + +CACHE_TARGETS := run-checks-ci + +.PHONY: cache-setup +cache-setup: + @mkdir -p "$(GOCACHE)" + +$(CACHE_TARGETS): export GOCACHE := $(GOCACHE) +$(CACHE_TARGETS): cache-setup + +%-cache: export GOCACHE := $(GOCACHE) +%-cache: cache-setup + $(MAKE) $* + +##@ Development + +.PHONY: manifests +manifests: controller-gen ## Generate WebhookConfiguration, ClusterRole and CustomResourceDefinition objects. + "$(CONTROLLER_GEN)" rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases + +.PHONY: generate +generate: controller-gen ## Generate code containing DeepCopy, DeepCopyInto, and DeepCopyObject method implementations. + "$(CONTROLLER_GEN)" object:headerFile="hack/boilerplate.go.txt" paths="./..." + +.PHONY: fmt +fmt: ## Run go fmt against code. + go fmt ./... + +.PHONY: vet +vet: ## Run go vet against code. + go vet ./... + +.PHONY: test +test: manifests generate fmt vet setup-envtest ## Run tests. + KUBEBUILDER_ASSETS="$(shell "$(ENVTEST)" use $(ENVTEST_K8S_VERSION) --bin-dir "$(LOCALBIN)" -p path)" go test $$(go list ./... | grep -v /e2e) -coverprofile cover.out + +.PHONY: test-ci +test-ci: manifests generate fmt vet setup-envtest ## Run tests (CI style). + DISREDIS_SKIP_ENVTEST=1 KUBEBUILDER_ASSETS="$(shell "$(ENVTEST)" use $(ENVTEST_K8S_VERSION) --bin-dir "$(LOCALBIN)" -p path)" go test $$(go list ./... | grep -v /e2e) -coverprofile cover.out + +.PHONY: run-checks-ci +run-checks-ci: ## Run CI checks with local Go caches. + $(MAKE) fmt + $(MAKE) generate + $(MAKE) manifests + $(MAKE) test-ci + $(MAKE) lint + +KIND_CLUSTER ?= dis-redis-operator-test-e2e +KIND_KUBECONFIG ?= $(LOCALBIN)/kubeconfig-$(KIND_CLUSTER) + +.PHONY: setup-test-e2e +setup-test-e2e: ## Set up a Kind cluster for e2e tests if it does not exist + @command -v $(KIND) >/dev/null 2>&1 || { \ + echo "Kind is not installed. Please install Kind manually."; \ + exit 1; \ + } + @case "$$($(KIND) get clusters)" in \ + *"$(KIND_CLUSTER)"*) \ + echo "Kind cluster '$(KIND_CLUSTER)' already exists. Skipping creation." ;; \ + *) \ + echo "Creating Kind cluster '$(KIND_CLUSTER)'..."; \ + $(KIND) create cluster --name $(KIND_CLUSTER) ;; \ + esac + @mkdir -p "$(LOCALBIN)" + @$(KIND) get kubeconfig --name $(KIND_CLUSTER) > "$(KIND_KUBECONFIG)" + +.PHONY: cleanup-test-e2e +cleanup-test-e2e: ## Tear down the Kind cluster used for e2e tests + @$(KIND) delete cluster --name $(KIND_CLUSTER) + @rm -f "$(KIND_KUBECONFIG)" + +.PHONY: lint +lint: golangci-lint ## Run golangci-lint linter + "$(GOLANGCI_LINT)" run + +.PHONY: lint-fix +lint-fix: golangci-lint ## Run golangci-lint linter and perform fixes + "$(GOLANGCI_LINT)" run --fix + +.PHONY: lint-config +lint-config: golangci-lint ## Verify golangci-lint linter configuration + "$(GOLANGCI_LINT)" config verify + +.PHONY: govulncheck +govulncheck: govulncheck-bin ## Run govulncheck against code and dependencies. + "$(GOVULNCHECK)" ./... + +##@ Build + +.PHONY: build +build: manifests generate fmt vet ## Build manager binary. + go build -o bin/manager ./cmd/main.go + +.PHONY: run +run: manifests generate fmt vet ## Run a controller from your host. + go run ./cmd/main.go + +.PHONY: docker-build +docker-build: ## Build docker image with the manager. + $(CONTAINER_TOOL) build -t ${IMG} . + +.PHONY: docker-push +docker-push: ## Push docker image with the manager. + $(CONTAINER_TOOL) push ${IMG} + +PLATFORMS ?= linux/arm64,linux/amd64,linux/s390x,linux/ppc64le +.PHONY: docker-buildx +docker-buildx: ## Build and push docker image for the manager for cross-platform support + sed -e '1 s/\(^FROM\)/FROM --platform=\$$\{BUILDPLATFORM\}/; t' -e ' 1,// s//FROM --platform=\$$\{BUILDPLATFORM\}/' Dockerfile > Dockerfile.cross + - $(CONTAINER_TOOL) buildx create --name dis-redis-operator-builder + $(CONTAINER_TOOL) buildx use dis-redis-operator-builder + - $(CONTAINER_TOOL) buildx build --push --platform=$(PLATFORMS) --tag ${IMG} -f Dockerfile.cross . + - $(CONTAINER_TOOL) buildx rm dis-redis-operator-builder + rm Dockerfile.cross + +.PHONY: build-installer +build-installer: manifests generate kustomize ## Generate a consolidated YAML with CRDs and deployment. + @set -e; \ + kustomize_file="config/manager/kustomization.yaml"; \ + kustomize_backup="$$(mktemp)"; \ + cp "$$kustomize_file" "$$kustomize_backup"; \ + trap 'cp "$$kustomize_backup" "$$kustomize_file"; rm -f "$$kustomize_backup"' EXIT; \ + mkdir -p dist; \ + ( cd config/manager && "$(KUSTOMIZE)" edit set image controller=${IMG} ); \ + "$(KUSTOMIZE)" build config/default > dist/install.yaml + +##@ Deployment + +.PHONY: kind-load +kind-load: setup-test-e2e + $(CONTAINER_TOOL) save -o $(KIND_IMAGE_ARCHIVE) $(IMG) + $(KIND) load image-archive --name $(KIND_CLUSTER) $(KIND_IMAGE_ARCHIVE) + +ifndef ignore-not-found + ignore-not-found = false +endif + +.PHONY: install +install: manifests kustomize ## Install CRDs into the K8s cluster specified in ~/.kube/config. + @out="$$( "$(KUSTOMIZE)" build config/crd 2>/dev/null || true )"; \ + if [ -n "$$out" ]; then echo "$$out" | "$(KUBECTL)" apply -f -; else echo "No CRDs to install; skipping."; fi + +.PHONY: uninstall +uninstall: manifests kustomize ## Uninstall CRDs from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion. + @out="$$( "$(KUSTOMIZE)" build config/crd 2>/dev/null || true )"; \ + if [ -n "$$out" ]; then echo "$$out" | "$(KUBECTL)" delete --ignore-not-found=$(ignore-not-found) -f -; else echo "No CRDs to delete; skipping."; fi + +.PHONY: deploy +deploy: manifests kustomize ## Deploy controller to the K8s cluster specified in ~/.kube/config. + @set -e; \ + kustomize_file="config/manager/kustomization.yaml"; \ + kustomize_backup="$$(mktemp)"; \ + cp "$$kustomize_file" "$$kustomize_backup"; \ + trap 'cp "$$kustomize_backup" "$$kustomize_file"; rm -f "$$kustomize_backup"' EXIT; \ + ( cd config/manager && "$(KUSTOMIZE)" edit set image controller=${IMG} ); \ + "$(KUSTOMIZE)" build config/default | "$(KUBECTL)" apply -f - + +.PHONY: undeploy +undeploy: kustomize ## Undeploy controller from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion. + "$(KUSTOMIZE)" build config/default | "$(KUBECTL)" delete --ignore-not-found=$(ignore-not-found) -f - + +##@ Dependencies + +## Location to install dependencies to +LOCALBIN ?= $(shell pwd)/bin +$(LOCALBIN): + mkdir -p "$(LOCALBIN)" + +## Tool Binaries +KUBECTL ?= kubectl +KIND ?= kind +KUSTOMIZE ?= $(LOCALBIN)/kustomize +CONTROLLER_GEN ?= $(LOCALBIN)/controller-gen +ENVTEST ?= $(LOCALBIN)/setup-envtest +GOLANGCI_LINT = $(LOCALBIN)/golangci-lint +GOVULNCHECK ?= $(LOCALBIN)/govulncheck + +## Tool Versions +KUSTOMIZE_VERSION ?= v5.7.1 +CONTROLLER_TOOLS_VERSION ?= v0.19.0 + +ENVTEST_VERSION ?= $(shell v='$(call gomodver,sigs.k8s.io/controller-runtime)'; \ + [ -n "$$v" ] || { echo "Set ENVTEST_VERSION manually (controller-runtime replace has no tag)" >&2; exit 1; }; \ + printf '%s\n' "$$v" | sed -E 's/^v?([0-9]+)\.([0-9]+).*/release-\1.\2/') + +ENVTEST_K8S_VERSION ?= $(shell v='$(call gomodver,k8s.io/api)'; \ + [ -n "$$v" ] || { echo "Set ENVTEST_K8S_VERSION manually (k8s.io/api replace has no tag)" >&2; exit 1; }; \ + printf '%s\n' "$$v" | sed -E 's/^v?[0-9]+\.([0-9]+).*/1.\1/') + +GOLANGCI_LINT_VERSION ?= v2.11.3 +GOVULNCHECK_VERSION ?= latest + +.PHONY: kustomize +kustomize: $(KUSTOMIZE) ## Download kustomize locally if necessary. +$(KUSTOMIZE): $(LOCALBIN) + $(call go-install-tool,$(KUSTOMIZE),sigs.k8s.io/kustomize/kustomize/v5,$(KUSTOMIZE_VERSION)) + +.PHONY: controller-gen +controller-gen: $(CONTROLLER_GEN) ## Download controller-gen locally if necessary. +$(CONTROLLER_GEN): $(LOCALBIN) + $(call go-install-tool,$(CONTROLLER_GEN),sigs.k8s.io/controller-tools/cmd/controller-gen,$(CONTROLLER_TOOLS_VERSION)) + +.PHONY: setup-envtest +setup-envtest: aso-crds dis-identity-crd envtest ## Download the binaries required for ENVTEST in the local bin directory. + @echo "Setting up envtest binaries for Kubernetes version $(ENVTEST_K8S_VERSION)..." + @"$(ENVTEST)" use $(ENVTEST_K8S_VERSION) --bin-dir "$(LOCALBIN)" -p path || { \ + echo "Error: Failed to set up envtest binaries for version $(ENVTEST_K8S_VERSION)."; \ + exit 1; \ + } + +.PHONY: envtest +envtest: $(ENVTEST) ## Download setup-envtest locally if necessary. +$(ENVTEST): $(LOCALBIN) + $(call go-install-tool,$(ENVTEST),sigs.k8s.io/controller-runtime/tools/setup-envtest,$(ENVTEST_VERSION)) + +.PHONY: golangci-lint +golangci-lint: $(GOLANGCI_LINT) ## Download golangci-lint locally if necessary. +$(GOLANGCI_LINT): $(LOCALBIN) + $(call go-install-tool,$(GOLANGCI_LINT),github.com/golangci/golangci-lint/v2/cmd/golangci-lint,$(GOLANGCI_LINT_VERSION)) + +.PHONY: govulncheck-bin +govulncheck-bin: $(GOVULNCHECK) ## Download govulncheck locally if necessary. +$(GOVULNCHECK): $(LOCALBIN) + $(call go-install-tool,$(GOVULNCHECK),golang.org/x/vuln/cmd/govulncheck,$(GOVULNCHECK_VERSION)) + +define go-install-tool +@[ -f "$(1)-$(3)" ] && [ "$$(readlink -- "$(1)" 2>/dev/null)" = "$(1)-$(3)" ] || { \ +set -e; \ +package=$(2)@$(3) ;\ +echo "Downloading $${package}" ;\ +rm -f "$(1)" ;\ +GOBIN="$(LOCALBIN)" go install $${package} ;\ +mv "$(LOCALBIN)/$$(basename "$(1)")" "$(1)-$(3)" ;\ +} ;\ +ln -sf "$$(realpath "$(1)-$(3)")" "$(1)" +endef + +define gomodver +$(shell GOWORK=off go list -m -f '{{if .Replace}}{{.Replace.Version}}{{else}}{{.Version}}{{end}}' $(1) 2>/dev/null) +endef + +ASO_CRD_VERSION ?= v2.17.0 +DIS_IDENTITY_CRD_NAME ?= application.dis.altinn.cloud_applicationidentities.yaml +DIS_IDENTITY_MODULE ?= github.com/Altinn/altinn-platform/services/dis-identity-operator +DIS_IDENTITY_CRD_REL_PATH ?= config/crd/bases/$(DIS_IDENTITY_CRD_NAME) +DIS_IDENTITY_CRD_LOCAL_FILE ?= $(WORKSPACE_ROOT)/services/dis-identity-operator/$(DIS_IDENTITY_CRD_REL_PATH) +DIS_IDENTITY_CRD_DIR ?= $(LOCALBIN)/dis-identity-crds +DIS_IDENTITY_CRD_FILE ?= $(DIS_IDENTITY_CRD_DIR)/$(DIS_IDENTITY_CRD_NAME) + +.PHONY: aso-crds +aso-crds: ## Download the ASO CRDs locally if necessary. + @mkdir -p $(LOCALBIN)/aso-crds + @if [ -f "$(LOCALBIN)/aso-crds/azureserviceoperator_customresourcedefinitions_$(ASO_CRD_VERSION).yaml" ]; then \ + echo "ASO CRDs already present; skipping download."; \ + else \ + curl -fsSL https://github.com/Azure/azure-service-operator/releases/download/$(ASO_CRD_VERSION)/azureserviceoperator_customresourcedefinitions_$(ASO_CRD_VERSION).yaml -o $(LOCALBIN)/aso-crds/azureserviceoperator_customresourcedefinitions_$(ASO_CRD_VERSION).yaml; \ + fi + +.PHONY: dis-identity-crd +dis-identity-crd: ## Download the ApplicationIdentity CRD locally if necessary. + @mkdir -p $(DIS_IDENTITY_CRD_DIR) + @if [ -f "$(DIS_IDENTITY_CRD_LOCAL_FILE)" ]; then \ + cp "$(DIS_IDENTITY_CRD_LOCAL_FILE)" "$(DIS_IDENTITY_CRD_FILE)"; \ + echo "ApplicationIdentity CRD copied from local workspace: $(DIS_IDENTITY_CRD_LOCAL_FILE)"; \ + else \ + module_dir="$$(GOWORK=off go list -m -f '{{.Dir}}' $(DIS_IDENTITY_MODULE) 2>/dev/null || true)"; \ + if [ -z "$$module_dir" ] || [ ! -f "$$module_dir/$(DIS_IDENTITY_CRD_REL_PATH)" ]; then \ + GOWORK=off go mod download $(DIS_IDENTITY_MODULE) >/dev/null; \ + module_dir="$$(GOWORK=off go list -m -f '{{.Dir}}' $(DIS_IDENTITY_MODULE) 2>/dev/null || true)"; \ + fi; \ + if [ -n "$$module_dir" ] && [ -f "$$module_dir/$(DIS_IDENTITY_CRD_REL_PATH)" ]; then \ + cp "$$module_dir/$(DIS_IDENTITY_CRD_REL_PATH)" "$(DIS_IDENTITY_CRD_FILE)"; \ + echo "ApplicationIdentity CRD copied from module source: $$module_dir/$(DIS_IDENTITY_CRD_REL_PATH)"; \ + elif [ -f "$(DIS_IDENTITY_CRD_FILE)" ]; then \ + echo "ApplicationIdentity CRD already present; skipping download."; \ + else \ + echo "Failed to resolve ApplicationIdentity CRD from workspace or module source." >&2; \ + exit 1; \ + fi; \ + fi diff --git a/services/dis-redis-operator/PROJECT b/services/dis-redis-operator/PROJECT new file mode 100644 index 000000000..d3c4a76c6 --- /dev/null +++ b/services/dis-redis-operator/PROJECT @@ -0,0 +1,11 @@ +# Code generated by tool. DO NOT EDIT. +# This file is used to track the info used to scaffold your project +# and allow the plugins properly work. +# More info: https://book.kubebuilder.io/reference/project-config.html +cliVersion: 4.10.1 +domain: dis.altinn.cloud +layout: +- go.kubebuilder.io/v4 +projectName: dis-redis-operator +repo: github.com/Altinn/altinn-platform/services/dis-redis-operator +version: "3" diff --git a/services/dis-redis-operator/README.md b/services/dis-redis-operator/README.md new file mode 100644 index 000000000..c5fdbe488 --- /dev/null +++ b/services/dis-redis-operator/README.md @@ -0,0 +1,13 @@ +# dis-redis-operator + +Self-service provisioning of Azure Managed Redis (`Microsoft.Cache/redisEnterprise`) for DIS application teams. App teams declare a `Redis` custom resource in their namespace, and the operator reconciles it into an Azure Redis Enterprise cluster + database with a private endpoint and Entra-only data-plane access. + +See [RFC 0014 - Self-service Managed Redis](../../rfcs/0014-self-service-managed-redis.md) for the full design. + +## Quick start + +- `make help` — list all targets. +- `make run-checks-ci-cache` — required pre-PR verification (fmt, generate, manifests, test, lint). +- `make install-cache && kubectl apply -f config/samples/` — install CRDs and apply a sample. + +See `AGENTS.md` for contribution conventions. diff --git a/services/dis-redis-operator/api/v1alpha1/groupversion_info.go b/services/dis-redis-operator/api/v1alpha1/groupversion_info.go new file mode 100644 index 000000000..7c5c6c604 --- /dev/null +++ b/services/dis-redis-operator/api/v1alpha1/groupversion_info.go @@ -0,0 +1,36 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package v1alpha1 contains API Schema definitions for the redis v1alpha1 API group. +// +kubebuilder:object:generate=true +// +groupName=redis.dis.altinn.cloud +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/scheme" +) + +var ( + // GroupVersion is group version used to register these objects. + GroupVersion = schema.GroupVersion{Group: "redis.dis.altinn.cloud", Version: "v1alpha1"} + + // SchemeBuilder is used to add go types to the GroupVersionKind scheme. + SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} + + // AddToScheme adds the types in this group-version to the given scheme. + AddToScheme = SchemeBuilder.AddToScheme +) diff --git a/services/dis-redis-operator/api/v1alpha1/redis_types.go b/services/dis-redis-operator/api/v1alpha1/redis_types.go new file mode 100644 index 000000000..61a89b5c2 --- /dev/null +++ b/services/dis-redis-operator/api/v1alpha1/redis_types.go @@ -0,0 +1,242 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// RedisSKU defines the allowed Azure Managed Redis SKU values. +// +kubebuilder:validation:Enum=Balanced_B0;Balanced_B1;Balanced_B3;Balanced_B5;Balanced_B10;MemoryOptimized_M10;MemoryOptimized_M20 +type RedisSKU string + +const ( + RedisSKUBalancedB0 RedisSKU = "Balanced_B0" + RedisSKUBalancedB1 RedisSKU = "Balanced_B1" + RedisSKUBalancedB3 RedisSKU = "Balanced_B3" + RedisSKUBalancedB5 RedisSKU = "Balanced_B5" + RedisSKUBalancedB10 RedisSKU = "Balanced_B10" + RedisSKUMemoryOptM10 RedisSKU = "MemoryOptimized_M10" + RedisSKUMemoryOptM20 RedisSKU = "MemoryOptimized_M20" +) + +// RedisClientProtocol defines the wire-level client protocol for the Redis database. +// +kubebuilder:validation:Enum=Encrypted;Plaintext +type RedisClientProtocol string + +const ( + RedisClientProtocolEncrypted RedisClientProtocol = "Encrypted" + RedisClientProtocolPlaintext RedisClientProtocol = "Plaintext" +) + +// RedisEvictionPolicy defines the Redis cache eviction policy. +// +kubebuilder:validation:Enum=AllKeysLFU;AllKeysLRU;AllKeysRandom;VolatileLFU;VolatileLRU;VolatileRandom;VolatileTTL;NoEviction +type RedisEvictionPolicy string + +const ( + RedisEvictionAllKeysLFU RedisEvictionPolicy = "AllKeysLFU" + RedisEvictionAllKeysLRU RedisEvictionPolicy = "AllKeysLRU" + RedisEvictionAllKeysRandom RedisEvictionPolicy = "AllKeysRandom" + RedisEvictionVolatileLFU RedisEvictionPolicy = "VolatileLFU" + RedisEvictionVolatileLRU RedisEvictionPolicy = "VolatileLRU" + RedisEvictionVolatileRandom RedisEvictionPolicy = "VolatileRandom" + RedisEvictionVolatileTTL RedisEvictionPolicy = "VolatileTTL" + RedisEvictionNoEviction RedisEvictionPolicy = "NoEviction" +) + +// RedisModuleName defines the supported optional Redis modules. +// +kubebuilder:validation:Enum=RedisJSON;RediSearch;RedisTimeSeries;RedisBloom +type RedisModuleName string + +// RedisModule enables a single optional Redis module on the database. +type RedisModule struct { + // Name is the module identifier. + // +kubebuilder:validation:Required + Name RedisModuleName `json:"name"` + + // Args are optional, module-specific arguments. + // +optional + Args string `json:"args,omitempty"` +} + +// RedisPersistence configures AOF / RDB persistence settings for the database. +// At most one of AOF or RDB may be enabled. +type RedisPersistence struct { + // AOF enables append-only-file persistence with the specified frequency. + // +optional + // +kubebuilder:validation:Enum=Always;Every1Second + AOF string `json:"aof,omitempty"` + + // RDB enables snapshot persistence with the specified frequency. + // +optional + // +kubebuilder:validation:Enum=1h;6h;12h + RDB string `json:"rdb,omitempty"` +} + +// ApplicationIdentityRef references an ApplicationIdentity in the same namespace. +type ApplicationIdentityRef struct { + // Name is the ApplicationIdentity name in the same namespace. + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` +} + +// ServiceAccountRef references a ServiceAccount in the same namespace. +type ServiceAccountRef struct { + // Name is the ServiceAccount name in the same namespace. + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` +} + +// RedisSpec defines the desired state of Redis. +// +kubebuilder:validation:XValidation:rule="has(self.identityRef) != has(self.serviceAccountRef)",message="exactly one of identityRef or serviceAccountRef must be set" +type RedisSpec struct { + // IdentityRef points to the owning ApplicationIdentity in the same namespace. + // +optional + IdentityRef *ApplicationIdentityRef `json:"identityRef,omitempty"` + + // ServiceAccountRef points to the owning ServiceAccount in the same namespace. + // +optional + ServiceAccountRef *ServiceAccountRef `json:"serviceAccountRef,omitempty"` + + // SKU drives cluster capacity. Defaults to the smallest Balanced tier. + // +optional + // +kubebuilder:default=Balanced_B0 + SKU RedisSKU `json:"sku,omitempty"` + + // HighAvailability spreads the cluster across availability zones. Defaults to true. + // +optional + // +kubebuilder:default=true + HighAvailability *bool `json:"highAvailability,omitempty"` + + // Version is the Redis version (e.g. "7", "7.4"). Optional; defaults to the ASO default. + // +optional + // +kubebuilder:validation:Pattern="^[0-9]+(\\.[0-9]+)?$" + Version string `json:"version,omitempty"` + + // ClientProtocol selects between Encrypted (TLS) and Plaintext. Defaults to Encrypted. + // +optional + // +kubebuilder:default=Encrypted + ClientProtocol RedisClientProtocol `json:"clientProtocol,omitempty"` + + // EvictionPolicy selects the database eviction policy. Defaults to NoEviction. + // +optional + // +kubebuilder:default=NoEviction + EvictionPolicy RedisEvictionPolicy `json:"evictionPolicy,omitempty"` + + // Modules is the optional list of Redis modules enabled on the database. + // +optional + Modules []RedisModule `json:"modules,omitempty"` + + // Persistence configures optional AOF / RDB persistence. Defaults to no persistence. + // +optional + Persistence *RedisPersistence `json:"persistence,omitempty"` + + // Tags are optional user-provided tags propagated to Azure resources. + // +optional + Tags map[string]string `json:"tags,omitempty"` +} + +// RedisStatus defines the observed state of Redis. +type RedisStatus struct { + // Conditions represent the current state of this Redis. + // +listType=map + // +listMapKey=type + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` + + // AzureName is the computed Azure Redis Enterprise cluster name. + // +optional + AzureName string `json:"azureName,omitempty"` + + // ClusterResourceID is the ARM resource ID of the Redis Enterprise cluster. + // +optional + ClusterResourceID string `json:"clusterResourceId,omitempty"` + + // DatabaseResourceID is the ARM resource ID of the Redis Enterprise database. + // +optional + DatabaseResourceID string `json:"databaseResourceId,omitempty"` + + // HostName is the resolved DNS hostname of the cluster (e.g. "..redis.azure.net"). + // +optional + HostName string `json:"hostName,omitempty"` + + // Port is the database client port (defaults to 10000 for Redis Enterprise). + // +optional + Port int32 `json:"port,omitempty"` + + // OwnerPrincipalID is the resolved owner principal ID. + // +optional + OwnerPrincipalID string `json:"ownerPrincipalId,omitempty"` + + // AccessPolicyAssignmentName is the name of the managed access policy assignment. + // +optional + AccessPolicyAssignmentName string `json:"accessPolicyAssignmentName,omitempty"` + + // ObservedGeneration is the latest generation reconciled by the controller. + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` +} + +// ConditionType represents status condition type names used by Redis. +type ConditionType string + +const ( + ConditionReady ConditionType = "Ready" + ConditionIdentityReady ConditionType = "IdentityReady" + ConditionClusterReady ConditionType = "ClusterReady" + ConditionDatabaseReady ConditionType = "DatabaseReady" + ConditionPrivateEndpointReady ConditionType = "PrivateEndpointReady" + ConditionPrivateDNSReady ConditionType = "PrivateDNSReady" + ConditionAccessPolicyReady ConditionType = "AccessPolicyReady" +) + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].status" +// +kubebuilder:printcolumn:name="Reason",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].reason" +// +kubebuilder:printcolumn:name="AzureName",type="string",JSONPath=".status.azureName" +// +kubebuilder:printcolumn:name="HostName",type="string",JSONPath=".status.hostName" + +// Redis is the Schema for the redises API. +type Redis struct { + metav1.TypeMeta `json:",inline"` + + // Metadata is standard object metadata. + // +optional + metav1.ObjectMeta `json:"metadata,omitzero"` + + // Spec defines the desired state of Redis. + // +required + Spec RedisSpec `json:"spec"` + + // Status defines the observed state of Redis. + // +optional + Status RedisStatus `json:"status,omitzero"` +} + +// +kubebuilder:object:root=true + +// RedisList contains a list of Redis. +type RedisList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitzero"` + Items []Redis `json:"items"` +} + +func init() { + SchemeBuilder.Register(&Redis{}, &RedisList{}) +} diff --git a/services/dis-redis-operator/api/v1alpha1/zz_generated.deepcopy.go b/services/dis-redis-operator/api/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 000000000..0bc78db97 --- /dev/null +++ b/services/dis-redis-operator/api/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,214 @@ +//go:build !ignore_autogenerated + +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by controller-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ApplicationIdentityRef) DeepCopyInto(out *ApplicationIdentityRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ApplicationIdentityRef. +func (in *ApplicationIdentityRef) DeepCopy() *ApplicationIdentityRef { + if in == nil { + return nil + } + out := new(ApplicationIdentityRef) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceAccountRef) DeepCopyInto(out *ServiceAccountRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceAccountRef. +func (in *ServiceAccountRef) DeepCopy() *ServiceAccountRef { + if in == nil { + return nil + } + out := new(ServiceAccountRef) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RedisModule) DeepCopyInto(out *RedisModule) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RedisModule. +func (in *RedisModule) DeepCopy() *RedisModule { + if in == nil { + return nil + } + out := new(RedisModule) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RedisPersistence) DeepCopyInto(out *RedisPersistence) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RedisPersistence. +func (in *RedisPersistence) DeepCopy() *RedisPersistence { + if in == nil { + return nil + } + out := new(RedisPersistence) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Redis) DeepCopyInto(out *Redis) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Redis. +func (in *Redis) DeepCopy() *Redis { + if in == nil { + return nil + } + out := new(Redis) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Redis) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RedisList) DeepCopyInto(out *RedisList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Redis, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RedisList. +func (in *RedisList) DeepCopy() *RedisList { + if in == nil { + return nil + } + out := new(RedisList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *RedisList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RedisSpec) DeepCopyInto(out *RedisSpec) { + *out = *in + if in.IdentityRef != nil { + in, out := &in.IdentityRef, &out.IdentityRef + *out = new(ApplicationIdentityRef) + **out = **in + } + if in.ServiceAccountRef != nil { + in, out := &in.ServiceAccountRef, &out.ServiceAccountRef + *out = new(ServiceAccountRef) + **out = **in + } + if in.HighAvailability != nil { + in, out := &in.HighAvailability, &out.HighAvailability + *out = new(bool) + **out = **in + } + if in.Modules != nil { + in, out := &in.Modules, &out.Modules + *out = make([]RedisModule, len(*in)) + copy(*out, *in) + } + if in.Persistence != nil { + in, out := &in.Persistence, &out.Persistence + *out = new(RedisPersistence) + **out = **in + } + if in.Tags != nil { + in, out := &in.Tags, &out.Tags + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RedisSpec. +func (in *RedisSpec) DeepCopy() *RedisSpec { + if in == nil { + return nil + } + out := new(RedisSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RedisStatus) DeepCopyInto(out *RedisStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RedisStatus. +func (in *RedisStatus) DeepCopy() *RedisStatus { + if in == nil { + return nil + } + out := new(RedisStatus) + in.DeepCopyInto(out) + return out +} diff --git a/services/dis-redis-operator/cmd/main.go b/services/dis-redis-operator/cmd/main.go new file mode 100644 index 000000000..5e5e5f197 --- /dev/null +++ b/services/dis-redis-operator/cmd/main.go @@ -0,0 +1,182 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "crypto/tls" + "flag" + "os" + + identityv1alpha1 "github.com/Altinn/altinn-platform/services/dis-identity-operator/api/v1alpha1" + redisv1alpha1 "github.com/Altinn/altinn-platform/services/dis-redis-operator/api/v1alpha1" + "github.com/Altinn/altinn-platform/services/dis-redis-operator/internal/config" + "github.com/Altinn/altinn-platform/services/dis-redis-operator/internal/controller" + cachev1 "github.com/Azure/azure-service-operator/v2/api/cache/v1api20250401" + pev1 "github.com/Azure/azure-service-operator/v2/api/network/v1api20220701" + networkv1 "github.com/Azure/azure-service-operator/v2/api/network/v1api20240601" + + _ "k8s.io/client-go/plugin/pkg/client/auth" + + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/healthz" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + "sigs.k8s.io/controller-runtime/pkg/metrics/filters" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + "sigs.k8s.io/controller-runtime/pkg/webhook" +) + +var ( + scheme = runtime.NewScheme() + setupLog = ctrl.Log.WithName("setup") +) + +func init() { + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(redisv1alpha1.AddToScheme(scheme)) + utilruntime.Must(identityv1alpha1.AddToScheme(scheme)) + utilruntime.Must(cachev1.AddToScheme(scheme)) + utilruntime.Must(networkv1.AddToScheme(scheme)) + utilruntime.Must(pev1.AddToScheme(scheme)) +} + +// nolint:gocyclo +func main() { + var ( + metricsAddr string + metricsCertPath, metricsCertName, metricsCertKey string + webhookCertPath, webhookCertName, webhookCertKey string + enableLeaderElection bool + probeAddr string + secureMetrics bool + enableHTTP2 bool + subscriptionID, resourceGroup, tenantID, location, env string + aksSubnetIDs, aksVNetID, dnsZoneResourceGroup string + tlsOpts []func(*tls.Config) + ) + + flag.StringVar(&metricsAddr, "metrics-bind-address", "0", "The address the metrics endpoint binds to. Use :8443 for HTTPS, :8080 for HTTP, or 0 to disable.") + flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") + flag.BoolVar(&enableLeaderElection, "leader-elect", false, "Enable leader election for controller manager.") + flag.BoolVar(&secureMetrics, "metrics-secure", true, "If set, the metrics endpoint is served securely via HTTPS.") + flag.StringVar(&webhookCertPath, "webhook-cert-path", "", "The directory that contains the webhook certificate.") + flag.StringVar(&webhookCertName, "webhook-cert-name", "tls.crt", "The name of the webhook certificate file.") + flag.StringVar(&webhookCertKey, "webhook-cert-key", "tls.key", "The name of the webhook key file.") + flag.StringVar(&metricsCertPath, "metrics-cert-path", "", "The directory that contains the metrics server certificate.") + flag.StringVar(&metricsCertName, "metrics-cert-name", "tls.crt", "The name of the metrics server certificate file.") + flag.StringVar(&metricsCertKey, "metrics-cert-key", "tls.key", "The name of the metrics server key file.") + flag.BoolVar(&enableHTTP2, "enable-http2", false, "If set, HTTP/2 will be enabled for the metrics and webhook servers") + + flag.StringVar(&subscriptionID, "subscription-id", os.Getenv("DISREDIS_AZURE_SUBSCRIPTION_ID"), "Azure subscription ID (required)") + flag.StringVar(&resourceGroup, "resource-group", os.Getenv("DISREDIS_RESOURCE_GROUP"), "Azure resource group for Redis resources (required)") + flag.StringVar(&tenantID, "tenant-id", os.Getenv("DISREDIS_AZURE_TENANT_ID"), "Azure tenant ID (required)") + flag.StringVar(&location, "location", os.Getenv("DISREDIS_LOCATION"), "Azure location for Redis resources (required)") + flag.StringVar(&env, "env", os.Getenv("DISREDIS_ENV"), "DIS environment identifier (required)") + flag.StringVar(&aksSubnetIDs, "aks-subnet-ids", os.Getenv("DISREDIS_AKS_SUBNET_IDS"), "Comma-separated AKS subnet ARM IDs (required)") + flag.StringVar(&aksVNetID, "aks-vnet-id", os.Getenv("DISREDIS_AKS_VNET_ID"), "AKS VNet ARM ID for the shared DNS zone link (required)") + flag.StringVar(&dnsZoneResourceGroup, "dns-zone-resource-group", os.Getenv("DISREDIS_DNS_ZONE_RESOURCE_GROUP"), "Resource group hosting the shared privatelink.redis.azure.net DNS zone (required)") + + opts := zap.Options{Development: true} + opts.BindFlags(flag.CommandLine) + flag.Parse() + + ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) + + disableHTTP2 := func(c *tls.Config) { + setupLog.Info("disabling http/2") + c.NextProtos = []string{"http/1.1"} + } + if !enableHTTP2 { + tlsOpts = append(tlsOpts, disableHTTP2) + } + + webhookServerOptions := webhook.Options{TLSOpts: tlsOpts} + if len(webhookCertPath) > 0 { + webhookServerOptions.CertDir = webhookCertPath + webhookServerOptions.CertName = webhookCertName + webhookServerOptions.KeyName = webhookCertKey + } + webhookServer := webhook.NewServer(webhookServerOptions) + + metricsServerOptions := metricsserver.Options{ + BindAddress: metricsAddr, + SecureServing: secureMetrics, + TLSOpts: tlsOpts, + } + if secureMetrics { + metricsServerOptions.FilterProvider = filters.WithAuthenticationAndAuthorization + } + if len(metricsCertPath) > 0 { + metricsServerOptions.CertDir = metricsCertPath + metricsServerOptions.CertName = metricsCertName + metricsServerOptions.KeyName = metricsCertKey + } + + mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ + Scheme: scheme, + Metrics: metricsServerOptions, + WebhookServer: webhookServer, + HealthProbeBindAddress: probeAddr, + LeaderElection: enableLeaderElection, + LeaderElectionID: "redis-operator.dis.altinn.cloud", + }) + if err != nil { + setupLog.Error(err, "unable to start manager") + os.Exit(1) + } + + opCfg, err := config.NewOperatorConfig( + subscriptionID, + resourceGroup, + tenantID, + location, + env, + aksSubnetIDs, + aksVNetID, + dnsZoneResourceGroup, + ) + if err != nil { + setupLog.Error(err, "invalid operator configuration") + os.Exit(1) + } + + if err = (&controller.RedisReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Config: *opCfg, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "Redis") + os.Exit(1) + } + + if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { + setupLog.Error(err, "unable to set up health check") + os.Exit(1) + } + if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { + setupLog.Error(err, "unable to set up ready check") + os.Exit(1) + } + + setupLog.Info("starting manager") + if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { + setupLog.Error(err, "problem running manager") + os.Exit(1) + } +} diff --git a/services/dis-redis-operator/config/crd/bases/redis.dis.altinn.cloud_redises.yaml b/services/dis-redis-operator/config/crd/bases/redis.dis.altinn.cloud_redises.yaml new file mode 100644 index 000000000..a2356068a --- /dev/null +++ b/services/dis-redis-operator/config/crd/bases/redis.dis.altinn.cloud_redises.yaml @@ -0,0 +1,189 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + name: redises.redis.dis.altinn.cloud +spec: + group: redis.dis.altinn.cloud + names: + kind: Redis + listKind: RedisList + plural: redises + singular: redis + scope: Namespaced + versions: + - name: v1alpha1 + served: true + storage: true + subresources: + status: {} + additionalPrinterColumns: + - jsonPath: .status.conditions[?(@.type=='Ready')].status + name: Ready + type: string + - jsonPath: .status.conditions[?(@.type=='Ready')].reason + name: Reason + type: string + - jsonPath: .status.azureName + name: AzureName + type: string + - jsonPath: .status.hostName + name: HostName + type: string + schema: + openAPIV3Schema: + description: Redis is the Schema for the redises API. + type: object + properties: + apiVersion: + type: string + kind: + type: string + metadata: + type: object + spec: + type: object + x-kubernetes-validations: + - rule: "has(self.identityRef) != has(self.serviceAccountRef)" + message: "exactly one of identityRef or serviceAccountRef must be set" + properties: + identityRef: + type: object + required: + - name + properties: + name: + type: string + minLength: 1 + serviceAccountRef: + type: object + required: + - name + properties: + name: + type: string + minLength: 1 + sku: + type: string + default: Balanced_B0 + enum: + - Balanced_B0 + - Balanced_B1 + - Balanced_B3 + - Balanced_B5 + - Balanced_B10 + - MemoryOptimized_M10 + - MemoryOptimized_M20 + highAvailability: + type: boolean + default: true + version: + type: string + pattern: '^[0-9]+(\.[0-9]+)?$' + clientProtocol: + type: string + default: Encrypted + enum: + - Encrypted + - Plaintext + evictionPolicy: + type: string + default: NoEviction + enum: + - AllKeysLFU + - AllKeysLRU + - AllKeysRandom + - VolatileLFU + - VolatileLRU + - VolatileRandom + - VolatileTTL + - NoEviction + modules: + type: array + items: + type: object + required: + - name + properties: + name: + type: string + enum: + - RedisJSON + - RediSearch + - RedisTimeSeries + - RedisBloom + args: + type: string + persistence: + type: object + properties: + aof: + type: string + enum: + - Always + - Every1Second + rdb: + type: string + enum: + - 1h + - 6h + - 12h + tags: + type: object + additionalProperties: + type: string + status: + type: object + properties: + conditions: + type: array + items: + type: object + required: + - lastTransitionTime + - message + - reason + - status + - type + properties: + lastTransitionTime: + type: string + format: date-time + message: + type: string + observedGeneration: + type: integer + format: int64 + reason: + type: string + status: + type: string + enum: + - "True" + - "False" + - Unknown + type: + type: string + x-kubernetes-list-type: map + x-kubernetes-list-map-keys: + - type + azureName: + type: string + clusterResourceId: + type: string + databaseResourceId: + type: string + hostName: + type: string + port: + type: integer + format: int32 + ownerPrincipalId: + type: string + accessPolicyAssignmentName: + type: string + observedGeneration: + type: integer + format: int64 diff --git a/services/dis-redis-operator/config/crd/kustomization.yaml b/services/dis-redis-operator/config/crd/kustomization.yaml new file mode 100644 index 000000000..9b350f039 --- /dev/null +++ b/services/dis-redis-operator/config/crd/kustomization.yaml @@ -0,0 +1,2 @@ +resources: +- bases/redis.dis.altinn.cloud_redises.yaml diff --git a/services/dis-redis-operator/config/default/deploy_vars_patch.yaml b/services/dis-redis-operator/config/default/deploy_vars_patch.yaml new file mode 100644 index 000000000..c4f36bdbf --- /dev/null +++ b/services/dis-redis-operator/config/default/deploy_vars_patch.yaml @@ -0,0 +1,19 @@ +- op: add + path: /spec/template/spec/containers/0/env + value: + - name: DISREDIS_AZURE_SUBSCRIPTION_ID + value: "${DISREDIS_AZURE_SUBSCRIPTION_ID}" + - name: DISREDIS_RESOURCE_GROUP + value: "${DISREDIS_RESOURCE_GROUP}" + - name: DISREDIS_AZURE_TENANT_ID + value: "${DISREDIS_AZURE_TENANT_ID}" + - name: DISREDIS_LOCATION + value: "${DISREDIS_LOCATION}" + - name: DISREDIS_ENV + value: "${DISREDIS_ENV}" + - name: DISREDIS_AKS_SUBNET_IDS + value: "${DISREDIS_AKS_SUBNET_IDS}" + - name: DISREDIS_AKS_VNET_ID + value: "${DISREDIS_AKS_VNET_ID}" + - name: DISREDIS_DNS_ZONE_RESOURCE_GROUP + value: "${DISREDIS_DNS_ZONE_RESOURCE_GROUP}" diff --git a/services/dis-redis-operator/config/default/kustomization.yaml b/services/dis-redis-operator/config/default/kustomization.yaml new file mode 100644 index 000000000..68299874c --- /dev/null +++ b/services/dis-redis-operator/config/default/kustomization.yaml @@ -0,0 +1,16 @@ +namespace: dis-redis-operator-system +namePrefix: dis-redis-operator- + +resources: +- ../crd +- ../rbac +- ../manager +- metrics_service.yaml + +patches: +- path: deploy_vars_patch.yaml + target: + kind: Deployment +- path: manager_metrics_patch.yaml + target: + kind: Deployment diff --git a/services/dis-redis-operator/config/default/manager_metrics_patch.yaml b/services/dis-redis-operator/config/default/manager_metrics_patch.yaml new file mode 100644 index 000000000..2aaef6536 --- /dev/null +++ b/services/dis-redis-operator/config/default/manager_metrics_patch.yaml @@ -0,0 +1,4 @@ +# This patch adds the args to allow exposing the metrics endpoint using HTTPS +- op: add + path: /spec/template/spec/containers/0/args/0 + value: --metrics-bind-address=:8443 diff --git a/services/dis-redis-operator/config/default/metrics_service.yaml b/services/dis-redis-operator/config/default/metrics_service.yaml new file mode 100644 index 000000000..ccfc05ee3 --- /dev/null +++ b/services/dis-redis-operator/config/default/metrics_service.yaml @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: Service +metadata: + labels: + control-plane: controller-manager + app.kubernetes.io/name: dis-redis-operator + app.kubernetes.io/managed-by: kustomize + name: controller-manager-metrics-service + namespace: system +spec: + ports: + - name: https + port: 8443 + protocol: TCP + targetPort: 8443 + selector: + control-plane: controller-manager + app.kubernetes.io/name: dis-redis-operator diff --git a/services/dis-redis-operator/config/kind/applicationidentities.yaml b/services/dis-redis-operator/config/kind/applicationidentities.yaml new file mode 100644 index 000000000..bd4f03ec3 --- /dev/null +++ b/services/dis-redis-operator/config/kind/applicationidentities.yaml @@ -0,0 +1,7 @@ +apiVersion: application.dis.altinn.cloud/v1alpha1 +kind: ApplicationIdentity +metadata: + name: app-identity-sample + namespace: default +spec: + displayName: Application identity sample for Kind e2e diff --git a/services/dis-redis-operator/config/kind/kustomization.yaml b/services/dis-redis-operator/config/kind/kustomization.yaml new file mode 100644 index 000000000..9a0a614c9 --- /dev/null +++ b/services/dis-redis-operator/config/kind/kustomization.yaml @@ -0,0 +1,14 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - ../default + - applicationidentities.yaml + - serviceaccounts.yaml + +patches: + - path: manager_kind_patch.yaml + target: + kind: Deployment + name: dis-redis-operator-controller-manager + namespace: dis-redis-operator-system diff --git a/services/dis-redis-operator/config/kind/manager_kind_patch.yaml b/services/dis-redis-operator/config/kind/manager_kind_patch.yaml new file mode 100644 index 000000000..95c367a72 --- /dev/null +++ b/services/dis-redis-operator/config/kind/manager_kind_patch.yaml @@ -0,0 +1,33 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: dis-redis-operator-controller-manager + namespace: dis-redis-operator-system +spec: + template: + spec: + containers: + - name: manager + imagePullPolicy: Never + args: + - --leader-elect + - --health-probe-bind-address=:8081 + - --metrics-bind-address=:8080 + - --metrics-secure=false + env: + - name: DISREDIS_AZURE_SUBSCRIPTION_ID + value: "fake-subscription" + - name: DISREDIS_RESOURCE_GROUP + value: "fake-resource-group" + - name: DISREDIS_AZURE_TENANT_ID + value: "00000000-0000-0000-0000-000000000000" + - name: DISREDIS_LOCATION + value: "norwayeast" + - name: DISREDIS_ENV + value: "dev" + - name: DISREDIS_AKS_SUBNET_IDS + value: "/subscriptions/fake-subscription/resourceGroups/fake-network-rg/providers/Microsoft.Network/virtualNetworks/fake-vnet/subnets/fake-aks-subnet" + - name: DISREDIS_AKS_VNET_ID + value: "/subscriptions/fake-subscription/resourceGroups/fake-network-rg/providers/Microsoft.Network/virtualNetworks/fake-vnet" + - name: DISREDIS_DNS_ZONE_RESOURCE_GROUP + value: "fake-resource-group" diff --git a/services/dis-redis-operator/config/kind/serviceaccounts.yaml b/services/dis-redis-operator/config/kind/serviceaccounts.yaml new file mode 100644 index 000000000..8dcd23938 --- /dev/null +++ b/services/dis-redis-operator/config/kind/serviceaccounts.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: redis-owner-sa + namespace: default + annotations: + azure.workload.identity/client-id: 11111111-1111-1111-1111-111111111111 + dis.altinn.cloud/principal-id: 22222222-2222-2222-2222-222222222222 diff --git a/services/dis-redis-operator/config/manager/kustomization.yaml b/services/dis-redis-operator/config/manager/kustomization.yaml new file mode 100644 index 000000000..5c5f0b84c --- /dev/null +++ b/services/dis-redis-operator/config/manager/kustomization.yaml @@ -0,0 +1,2 @@ +resources: +- manager.yaml diff --git a/services/dis-redis-operator/config/manager/manager.yaml b/services/dis-redis-operator/config/manager/manager.yaml new file mode 100644 index 000000000..d07a2d8c8 --- /dev/null +++ b/services/dis-redis-operator/config/manager/manager.yaml @@ -0,0 +1,74 @@ +apiVersion: v1 +kind: Namespace +metadata: + labels: + control-plane: controller-manager + app.kubernetes.io/name: dis-redis-operator + app.kubernetes.io/managed-by: kustomize + name: system +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: controller-manager + namespace: system + labels: + control-plane: controller-manager + app.kubernetes.io/name: dis-redis-operator + app.kubernetes.io/managed-by: kustomize +spec: + selector: + matchLabels: + control-plane: controller-manager + app.kubernetes.io/name: dis-redis-operator + replicas: 1 + template: + metadata: + annotations: + kubectl.kubernetes.io/default-container: manager + labels: + control-plane: controller-manager + app.kubernetes.io/name: dis-redis-operator + spec: + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + containers: + - command: + - /manager + args: + - --leader-elect + - --health-probe-bind-address=:8081 + image: controller:latest + name: manager + ports: [] + securityContext: + readOnlyRootFilesystem: true + allowPrivilegeEscalation: false + capabilities: + drop: + - "ALL" + livenessProbe: + httpGet: + path: /healthz + port: 8081 + initialDelaySeconds: 15 + periodSeconds: 20 + readinessProbe: + httpGet: + path: /readyz + port: 8081 + initialDelaySeconds: 5 + periodSeconds: 10 + resources: + limits: + cpu: 500m + memory: 128Mi + requests: + cpu: 10m + memory: 64Mi + volumeMounts: [] + volumes: [] + serviceAccountName: controller-manager + terminationGracePeriodSeconds: 10 diff --git a/services/dis-redis-operator/config/network-policy/allow-metrics-traffic.yaml b/services/dis-redis-operator/config/network-policy/allow-metrics-traffic.yaml new file mode 100644 index 000000000..82f7a6613 --- /dev/null +++ b/services/dis-redis-operator/config/network-policy/allow-metrics-traffic.yaml @@ -0,0 +1,23 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + labels: + app.kubernetes.io/name: dis-redis-operator + app.kubernetes.io/managed-by: kustomize + name: allow-metrics-traffic + namespace: system +spec: + podSelector: + matchLabels: + control-plane: controller-manager + app.kubernetes.io/name: dis-redis-operator + policyTypes: + - Ingress + ingress: + - from: + - namespaceSelector: + matchLabels: + metrics: enabled + ports: + - port: 8443 + protocol: TCP diff --git a/services/dis-redis-operator/config/network-policy/kustomization.yaml b/services/dis-redis-operator/config/network-policy/kustomization.yaml new file mode 100644 index 000000000..ec0fb5e57 --- /dev/null +++ b/services/dis-redis-operator/config/network-policy/kustomization.yaml @@ -0,0 +1,2 @@ +resources: +- allow-metrics-traffic.yaml diff --git a/services/dis-redis-operator/config/prometheus/kustomization.yaml b/services/dis-redis-operator/config/prometheus/kustomization.yaml new file mode 100644 index 000000000..ed137168a --- /dev/null +++ b/services/dis-redis-operator/config/prometheus/kustomization.yaml @@ -0,0 +1,2 @@ +resources: +- monitor.yaml diff --git a/services/dis-redis-operator/config/prometheus/monitor.yaml b/services/dis-redis-operator/config/prometheus/monitor.yaml new file mode 100644 index 000000000..55791cc0f --- /dev/null +++ b/services/dis-redis-operator/config/prometheus/monitor.yaml @@ -0,0 +1,21 @@ +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + labels: + control-plane: controller-manager + app.kubernetes.io/name: dis-redis-operator + app.kubernetes.io/managed-by: kustomize + name: controller-manager-metrics-monitor + namespace: system +spec: + endpoints: + - path: /metrics + port: https + scheme: https + bearerTokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token + tlsConfig: + insecureSkipVerify: true + selector: + matchLabels: + control-plane: controller-manager + app.kubernetes.io/name: dis-redis-operator diff --git a/services/dis-redis-operator/config/rbac/kustomization.yaml b/services/dis-redis-operator/config/rbac/kustomization.yaml new file mode 100644 index 000000000..af4a11865 --- /dev/null +++ b/services/dis-redis-operator/config/rbac/kustomization.yaml @@ -0,0 +1,9 @@ +resources: +- service_account.yaml +- role.yaml +- role_binding.yaml +- leader_election_role.yaml +- leader_election_role_binding.yaml +- metrics_auth_role.yaml +- metrics_auth_role_binding.yaml +- metrics_reader_role.yaml diff --git a/services/dis-redis-operator/config/rbac/leader_election_role.yaml b/services/dis-redis-operator/config/rbac/leader_election_role.yaml new file mode 100644 index 000000000..cfc124391 --- /dev/null +++ b/services/dis-redis-operator/config/rbac/leader_election_role.yaml @@ -0,0 +1,39 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + labels: + app.kubernetes.io/name: dis-redis-operator + app.kubernetes.io/managed-by: kustomize + name: leader-election-role +rules: +- apiGroups: + - "" + resources: + - configmaps + verbs: + - get + - list + - watch + - create + - update + - patch + - delete +- apiGroups: + - coordination.k8s.io + resources: + - leases + verbs: + - get + - list + - watch + - create + - update + - patch + - delete +- apiGroups: + - "" + resources: + - events + verbs: + - create + - patch diff --git a/services/dis-redis-operator/config/rbac/leader_election_role_binding.yaml b/services/dis-redis-operator/config/rbac/leader_election_role_binding.yaml new file mode 100644 index 000000000..189cbc91a --- /dev/null +++ b/services/dis-redis-operator/config/rbac/leader_election_role_binding.yaml @@ -0,0 +1,15 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + labels: + app.kubernetes.io/name: dis-redis-operator + app.kubernetes.io/managed-by: kustomize + name: leader-election-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: leader-election-role +subjects: +- kind: ServiceAccount + name: controller-manager + namespace: system diff --git a/services/dis-redis-operator/config/rbac/metrics_auth_role.yaml b/services/dis-redis-operator/config/rbac/metrics_auth_role.yaml new file mode 100644 index 000000000..32d2e4ec6 --- /dev/null +++ b/services/dis-redis-operator/config/rbac/metrics_auth_role.yaml @@ -0,0 +1,17 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: metrics-auth-role +rules: +- apiGroups: + - authentication.k8s.io + resources: + - tokenreviews + verbs: + - create +- apiGroups: + - authorization.k8s.io + resources: + - subjectaccessreviews + verbs: + - create diff --git a/services/dis-redis-operator/config/rbac/metrics_auth_role_binding.yaml b/services/dis-redis-operator/config/rbac/metrics_auth_role_binding.yaml new file mode 100644 index 000000000..e775d67ff --- /dev/null +++ b/services/dis-redis-operator/config/rbac/metrics_auth_role_binding.yaml @@ -0,0 +1,12 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: metrics-auth-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: metrics-auth-role +subjects: +- kind: ServiceAccount + name: controller-manager + namespace: system diff --git a/services/dis-redis-operator/config/rbac/metrics_reader_role.yaml b/services/dis-redis-operator/config/rbac/metrics_reader_role.yaml new file mode 100644 index 000000000..51a75db47 --- /dev/null +++ b/services/dis-redis-operator/config/rbac/metrics_reader_role.yaml @@ -0,0 +1,9 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: metrics-reader +rules: +- nonResourceURLs: + - "/metrics" + verbs: + - get diff --git a/services/dis-redis-operator/config/rbac/role.yaml b/services/dis-redis-operator/config/rbac/role.yaml new file mode 100644 index 000000000..2fdfb8e52 --- /dev/null +++ b/services/dis-redis-operator/config/rbac/role.yaml @@ -0,0 +1,88 @@ +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: manager-role +rules: +- apiGroups: + - "" + resources: + - serviceaccounts + verbs: + - get + - list + - watch +- apiGroups: + - application.dis.altinn.cloud + resources: + - applicationidentities + verbs: + - get + - list + - watch +- apiGroups: + - cache.azure.com + resources: + - redisenterprises + - redisenterprisesdatabases + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - cache.azure.com + resources: + - redisenterprises/status + - redisenterprisesdatabases/status + verbs: + - get + - patch + - update +- apiGroups: + - network.azure.com + resources: + - privateendpoints + - privatednszones + - privatednszonesvirtualnetworklinks + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - network.azure.com + resources: + - privateendpoints/status + - privatednszones/status + - privatednszonesvirtualnetworklinks/status + verbs: + - get + - patch + - update +- apiGroups: + - redis.dis.altinn.cloud + resources: + - redises + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - redis.dis.altinn.cloud + resources: + - redises/status + verbs: + - get + - patch + - update diff --git a/services/dis-redis-operator/config/rbac/role_binding.yaml b/services/dis-redis-operator/config/rbac/role_binding.yaml new file mode 100644 index 000000000..606d0c7bb --- /dev/null +++ b/services/dis-redis-operator/config/rbac/role_binding.yaml @@ -0,0 +1,15 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + labels: + app.kubernetes.io/name: dis-redis-operator + app.kubernetes.io/managed-by: kustomize + name: manager-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: manager-role +subjects: +- kind: ServiceAccount + name: controller-manager + namespace: system diff --git a/services/dis-redis-operator/config/rbac/service_account.yaml b/services/dis-redis-operator/config/rbac/service_account.yaml new file mode 100644 index 000000000..899a8af5e --- /dev/null +++ b/services/dis-redis-operator/config/rbac/service_account.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + labels: + app.kubernetes.io/name: dis-redis-operator + app.kubernetes.io/managed-by: kustomize + name: controller-manager + namespace: system diff --git a/services/dis-redis-operator/config/samples/kustomization.yaml b/services/dis-redis-operator/config/samples/kustomization.yaml new file mode 100644 index 000000000..8febc70f5 --- /dev/null +++ b/services/dis-redis-operator/config/samples/kustomization.yaml @@ -0,0 +1,3 @@ +namespace: default +resources: +- redis_v1alpha1_redis.yaml diff --git a/services/dis-redis-operator/config/samples/redis_v1alpha1_redis.yaml b/services/dis-redis-operator/config/samples/redis_v1alpha1_redis.yaml new file mode 100644 index 000000000..94307e1f2 --- /dev/null +++ b/services/dis-redis-operator/config/samples/redis_v1alpha1_redis.yaml @@ -0,0 +1,17 @@ +apiVersion: redis.dis.altinn.cloud/v1alpha1 +kind: Redis +metadata: + labels: + app.kubernetes.io/name: dis-redis-operator + app.kubernetes.io/managed-by: kustomize + name: redis-sample +spec: + identityRef: + name: app-identity-sample + sku: Balanced_B0 + highAvailability: true + clientProtocol: Encrypted + evictionPolicy: NoEviction + tags: + app: sample + env: dev diff --git a/services/dis-redis-operator/config/samples/redis_v1alpha1_service_account_redis.yaml b/services/dis-redis-operator/config/samples/redis_v1alpha1_service_account_redis.yaml new file mode 100644 index 000000000..d923254bf --- /dev/null +++ b/services/dis-redis-operator/config/samples/redis_v1alpha1_service_account_redis.yaml @@ -0,0 +1,17 @@ +apiVersion: redis.dis.altinn.cloud/v1alpha1 +kind: Redis +metadata: + labels: + app.kubernetes.io/name: dis-redis-operator + app.kubernetes.io/managed-by: kustomize + name: redis-service-account-sample +spec: + serviceAccountRef: + name: redis-owner-sa + sku: Balanced_B0 + highAvailability: true + clientProtocol: Encrypted + evictionPolicy: NoEviction + tags: + app: service-account-sample + env: dev diff --git a/services/dis-redis-operator/go.mod b/services/dis-redis-operator/go.mod new file mode 100644 index 000000000..48cbb82cc --- /dev/null +++ b/services/dis-redis-operator/go.mod @@ -0,0 +1,109 @@ +module github.com/Altinn/altinn-platform/services/dis-redis-operator + +go 1.26.3 + +require ( + github.com/Altinn/altinn-platform/services/dis-identity-operator v0.0.0-20260319083500-bd4f5f84c472 + github.com/Azure/azure-service-operator/v2 v2.17.0 + github.com/onsi/ginkgo/v2 v2.27.2 + github.com/onsi/gomega v1.38.2 + k8s.io/api v0.35.1 + k8s.io/apimachinery v0.35.1 + k8s.io/client-go v0.35.1 + sigs.k8s.io/controller-runtime v0.23.1 +) + +require ( + cel.dev/expr v0.24.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect + github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/benbjohnson/clock v1.3.5 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/blang/semver/v4 v4.0.0 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-logr/zapr v1.3.0 // indirect + github.com/go-openapi/jsonpointer v0.22.3 // indirect + github.com/go-openapi/jsonreference v0.21.0 // indirect + github.com/go-openapi/swag v0.23.1 // indirect + github.com/go-openapi/swag/jsonname v0.25.1 // indirect + github.com/go-task/slim-sprig/v3 v3.0.0 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/cel-go v0.26.1 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jellydator/ttlcache/v3 v3.4.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.9.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + github.com/rotisserie/eris v0.5.4 // indirect + github.com/samber/lo v1.52.0 // indirect + github.com/spf13/cobra v1.10.1 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/stoewer/go-strcase v1.3.0 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/otel v1.40.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 // indirect + go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/sdk v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect + go.opentelemetry.io/proto/otlp v1.9.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.1 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect + golang.org/x/mod v0.31.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/term v0.39.0 // indirect + golang.org/x/text v0.33.0 // indirect + golang.org/x/time v0.14.0 // indirect + golang.org/x/tools v0.40.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/grpc v1.78.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/apiextensions-apiserver v0.35.0 // indirect + k8s.io/apiserver v0.35.0 // indirect + k8s.io/component-base v0.35.0 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect + k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect + sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.32.0 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) diff --git a/services/dis-redis-operator/go.sum b/services/dis-redis-operator/go.sum new file mode 100644 index 000000000..33d7e3c72 --- /dev/null +++ b/services/dis-redis-operator/go.sum @@ -0,0 +1,381 @@ +cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= +cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/Altinn/altinn-platform/services/dis-identity-operator v0.0.0-20260319083500-bd4f5f84c472 h1:NsJwNEiCeUqll7Eyc4hn8akSFqG91/vGt+Jd5mCC3e4= +github.com/Altinn/altinn-platform/services/dis-identity-operator v0.0.0-20260319083500-bd4f5f84c472/go.mod h1:nqd3YPiMaqjV6BrPje5MivZP9LWjSNccAuFKmOkmvGM= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 h1:JXg2dwJUmPB9JmtVmdEB16APJ7jurfbY5jnfXpJoRMc= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/apimanagement/armapimanagement/v2 v2.1.0 h1:WYADp5XlioccEnBBK9sVUaHVno76l7WeTcWCumN86kM= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/apimanagement/armapimanagement/v2 v2.1.0/go.mod h1:PK8v1aAd2Wx6eTcbUYhYstGpspqNqhZYiM8GLFdq2A0= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appconfiguration/armappconfiguration v1.1.1 h1:iRc20pGuVlc1HwRO2bg0m1tfP9rkPB0K88trl8Fei2w= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appconfiguration/armappconfiguration v1.1.1/go.mod h1:21Lewei+tg5zp5xmyOxfDY//2tBvWQXee0UoM8xZjr8= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v2 v2.2.0 h1:Hp+EScFOu9HeCbeW8WU2yQPJd4gGwhMgKxWe+G6jNzw= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v2 v2.2.0/go.mod h1:/pz8dyNQe+Ey3yBp/XuYz7oqX8YDNWVpPB0hH3XWfbc= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices v1.8.0 h1:ZMGAqCZov8+7iFUPWKVcTaLgNXUeTlz20sIuWkQWNfg= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices v1.8.0/go.mod h1:BElPQ/GZtrdQ2i5uDZw3OKLE1we75W0AEWyeBR1TWQA= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice v1.0.0 h1:figxyQZXzZQIcP3njhC68bYUiTw45J8/SsHaLW8Ax0M= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice v1.0.0/go.mod h1:TmlMW4W5OvXOmOyKNnor8nlMMiO1ctIyzmHme/VHsrA= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cosmos/armcosmos v1.0.0 h1:Fv8iibGn1eSw0lt2V3cTsuokBEnOP+M//n8OiMcCgTM= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cosmos/armcosmos v1.0.0/go.mod h1:Qpe/qN9d5IQ7WPtTXMRCd6+BWTnhi3sxXVys6oJ5Vho= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dataprotection/armdataprotection/v3 v3.1.0 h1:Yj6NV1y8Deg7leXETiM9gJ+peM9DxhLR3GmppUSH+a0= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dataprotection/armdataprotection/v3 v3.1.0/go.mod h1:4lNPcTKG4Zgad7aiZBmvLfIMX47eqr5BFzDjC4zggKU= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/eventgrid/armeventgrid v1.0.0 h1:w6b0+FygDpqM7g5cjbeyPoBzgxVHwwt2vCUvTz1oFY8= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/eventgrid/armeventgrid v1.0.0/go.mod h1:t8kRpcgm+RdImuJgHG6SfoQ0tpb9LGl7MF1E6u0yeeA= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/eventhub/armeventhub v1.3.0 h1:4hGvxD72TluuFIXVr8f4XkKZfqAa7Pj61t0jmQ7+kes= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/eventhub/armeventhub v1.3.0/go.mod h1:TSH7DcFItwAufy0Lz+Ft2cyopExCpxbOxI5SkH4dRNo= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/iothub/armiothub v1.3.0 h1:NZP+oPbAVFy7PhQ4PTD3SuGWbEziNhp7lphGkkN707s= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/iothub/armiothub v1.3.0/go.mod h1:djbLk3ngutFfQ9fSOM29UzywAkcBI1YUsuUnxTQGsqU= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0 h1:nnQ9vXH039UrEFxi08pPuZBE7VfqSJt343uJLw0rhWI= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0/go.mod h1:4YIVtzMFVsPwBvitCDX7J9sqthSj43QD1sP6fYc1egc= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/machinelearning/armmachinelearning v1.0.0 h1:KWvCVjnOTKCZAlqED5KPNoN9AfcK2BhUeveLdiwy33Q= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/machinelearning/armmachinelearning v1.0.0/go.mod h1:qNN4I5AKYbXMLriS9XKebBw8EVIQkX6tJzrdtjOoJ4I= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork v1.1.0 h1:QM6sE5k2ZT/vI5BEe0r7mqjsUSnhVBFbOsVkEuaEfiA= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork v1.1.0/go.mod h1:243D9iHbcQXoFUtgHJwL7gl2zx1aDuDMjvBZVGr2uW0= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/notificationhubs/armnotificationhubs v1.2.0 h1:ZzshIzB4SnLLHFFHbBMxG5Sn2QCo9LWl4K5Nqz0Eysk= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/notificationhubs/armnotificationhubs v1.2.0/go.mod h1:YuV5NCOq5toIonfwdVfw2j99Uuy25Df2Tb5O83ZIuV4= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/operationalinsights/armoperationalinsights v1.2.0 h1:4FlNvfcPu7tTvOgOzXxIbZLvwvmZq1OdhQUdIa9g2N4= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/operationalinsights/armoperationalinsights v1.2.0/go.mod h1:A4nzEXwVd5pAyneR6KOvUAo72svUc5rmCzRHhAbP6lA= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/redhatopenshift/armredhatopenshift v1.6.0 h1:66BMYGcSQ+uMaWXiB1Kaf6PDauXGMVSLxdA9SGlE6Sc= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/redhatopenshift/armredhatopenshift v1.6.0/go.mod h1:/t2+d3mPIi1Ul1eXtxc5y9qmxMD3oHfSlCibhYKIB/U= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/redis/armredis v1.0.0 h1:nmpTBgRg1HynngFYICRhceC7s5dmbKN9fJ/XQz/UQ2I= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/redis/armredis v1.0.0/go.mod h1:3yjiOtnkVociBTlF7UZrwAGfJrGaOCsvtVS4HzNajxQ= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/redisenterprise/armredisenterprise v1.2.0 h1:hTmVmyvriwO+ymGLEsH7HZokVwinC2MZl8F0LjvPdHU= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/redisenterprise/armredisenterprise v1.2.0/go.mod h1:uHEpZj4TWSZEp35rIByJ8RX7hQBm3bxfPxS4tiz+x+g= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/search/armsearch v1.4.0 h1:zBdabY8pMSMLPb1XJnFSEdJi9Bd0h+VMjh1uU8B6Yp8= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/search/armsearch v1.4.0/go.mod h1:Y2Q3nB3UfSnG9nALOpPAjflXPM3jL/n2ZmYIu2Occ9g= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/servicebus/armservicebus v1.2.0 h1:jngSeKBnzC7qIk3rvbWHsLI7eeasEucORHWr2CHX0Yg= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/servicebus/armservicebus v1.2.0/go.mod h1:1YXAxWw6baox+KafeQU2scy21/4IHvqXoIJuCpcvpMQ= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/signalr/armsignalr v1.2.0 h1:Y8CF7FyuVVDyX5W6Azwjj3PpwUZVbXBOCyQytv/0QEA= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/signalr/armsignalr v1.2.0/go.mod h1:tzUx/enAY8RSmQhRq02uVZFeRJxdGYT6BqXwHiHoOcU= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1 h1:/Zt+cDPnpC3OVDm/JKLOs7M2DKmLRIIp3XIx9pHHiig= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1/go.mod h1:Ng3urmn6dYe8gnbCMoHHVl5APYz2txho3koEkV2o2HA= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/subscription/armsubscription v1.2.0 h1:UrGzkHueDwAWDdjQxC+QaXHd4tVCkISYE9j7fSSXF8k= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/subscription/armsubscription v1.2.0/go.mod h1:qskvSQeW+cxEE2bcKYyKimB1/KiQ9xpJ99bcHY0BX6c= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.3.1 h1:mrkDCdkMsD4l9wjFGhofFHFrV43Y3c53RSLKOCJ5+Ow= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.3.1/go.mod h1:hPv41DbqMmnxcGralanA/kVlfdH5jv3T4LxGku2E1BY= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 h1:bFWuoEKg+gImo7pvkiQEFAc8ocibADgXeiLAxWhWmkI= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h1:Vih/3yc6yac2JzU4hzpaDupBJP0Flaia9rXXrU8xyww= +github.com/Azure/azure-service-operator/v2 v2.17.0 h1:dfEG9npJmWNEtGenQ5a2LqDIuqQCeAKvxcrF34PxFeU= +github.com/Azure/azure-service-operator/v2 v2.17.0/go.mod h1:IwWu+T+LgIn8awMfQ3pg7Q30VGnDVBWO3tiLiU+cn+w= +github.com/Azure/msi-dataplane v0.4.3 h1:dWPWzY4b54tLIR9T1Q014Xxd/1DxOsMIp6EjRFAJlQY= +github.com/Azure/msi-dataplane v0.4.3/go.mod h1:yAfxdJyvcnvSDfSyOFV9qm4fReEQDl+nZLGeH2ZWSmw= +github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs= +github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= +github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= +github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= +github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= +github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch v5.9.0+incompatible h1:fBXyNpNMuTTDdquAq/uisOr2lShz4oaXpDTX2bLe7ls= +github.com/evanphx/json-patch v5.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= +github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= +github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= +github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= +github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= +github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-openapi/jsonpointer v0.22.3 h1:dKMwfV4fmt6Ah90zloTbUKWMD+0he+12XYAsPotrkn8= +github.com/go-openapi/jsonpointer v0.22.3/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo= +github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= +github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= +github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= +github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= +github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU= +github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo= +github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= +github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= +github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= +github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= +github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/cel-go v0.26.1 h1:iPbVVEdkhTX++hpe3lzSk7D3G3QSYqLGoHOcEio+UXQ= +github.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= +github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY= +github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= +github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4= +github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c= +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= +github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= +github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= +github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= +github.com/microsoft/go-mssqldb v1.9.4 h1:sHrj3GcdgkxytZ09aZ3+ys72pMeyEXJowT44j74pNgs= +github.com/microsoft/go-mssqldb v1.9.4/go.mod h1:GBbW9ASTiDC+mpgWDGKdm3FnFLTUsLYN3iFL90lQ+PA= +github.com/microsoft/kiota-abstractions-go v1.9.3 h1:cqhbqro+VynJ7kObmo7850h3WN2SbvoyhypPn8uJ1SE= +github.com/microsoft/kiota-abstractions-go v1.9.3/go.mod h1:f06pl3qSyvUHEfVNkiRpXPkafx7khZqQEb71hN/pmuU= +github.com/microsoft/kiota-authentication-azure-go v1.3.1 h1:AGta92S6IL1E6ZMDb8YYB7NVNTIFUakbtLKUdY5RTuw= +github.com/microsoft/kiota-authentication-azure-go v1.3.1/go.mod h1:26zylt2/KfKwEWZSnwHaMxaArpbyN/CuzkbotdYXF0g= +github.com/microsoft/kiota-http-go v1.5.4 h1:wSUmL1J+bTQlAWHjbRkSwr+SPAkMVYeYxxB85Zw0KFs= +github.com/microsoft/kiota-http-go v1.5.4/go.mod h1:L+5Ri+SzwELnUcNA0cpbFKp/pBbvypLh3Cd1PR6sjx0= +github.com/microsoft/kiota-serialization-form-go v1.1.2 h1:SD6MATqNw+Dc5beILlsb/D87C36HKC/Zw7l+N9+HY2A= +github.com/microsoft/kiota-serialization-form-go v1.1.2/go.mod h1:m4tY2JT42jAZmgbqFwPy3zGDF+NPJACuyzmjNXeuHio= +github.com/microsoft/kiota-serialization-json-go v1.1.2 h1:eJrPWeQ665nbjO0gsHWJ0Bw6V/ZHHU1OfFPaYfRG39k= +github.com/microsoft/kiota-serialization-json-go v1.1.2/go.mod h1:deaGt7fjZarywyp7TOTiRsjfYiyWxwJJPQZytXwYQn8= +github.com/microsoft/kiota-serialization-multipart-go v1.1.2 h1:1pUyA1QgIeKslQwbk7/ox1TehjlCUUT3r1f8cNlkvn4= +github.com/microsoft/kiota-serialization-multipart-go v1.1.2/go.mod h1:j2K7ZyYErloDu7Kuuk993DsvfoP7LPWvAo7rfDpdPio= +github.com/microsoft/kiota-serialization-text-go v1.1.3 h1:8z7Cebn0YAAr++xswVgfdxZjnAZ4GOB9O7XP4+r5r/M= +github.com/microsoft/kiota-serialization-text-go v1.1.3/go.mod h1:NDSvz4A3QalGMjNboKKQI9wR+8k+ih8UuagNmzIRgTQ= +github.com/microsoftgraph/msgraph-sdk-go v1.90.0 h1:ygVeWfGB8TMO4rTFxtrYueZmj1mLqtDKW5UZ4iJwczU= +github.com/microsoftgraph/msgraph-sdk-go v1.90.0/go.mod h1:UdZWxbZiFvjPug9DYayD90JNiHjXyNRA39lEpcy3Kms= +github.com/microsoftgraph/msgraph-sdk-go-core v1.4.0 h1:0SrIoFl7TQnMRrsi5TFaeNe0q8KO5lRzRp4GSCCL2So= +github.com/microsoftgraph/msgraph-sdk-go-core v1.4.0/go.mod h1:A1iXs+vjsRjzANxF6UeKv2ACExG7fqTwHHbwh1FL+EE= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= +github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= +github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rotisserie/eris v0.5.4 h1:Il6IvLdAapsMhvuOahHWiBnl1G++Q0/L5UIkI5mARSk= +github.com/rotisserie/eris v0.5.4/go.mod h1:Z/kgYTJiJtocxCbFfvRmO+QejApzG6zpyky9G1A4g9s= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw= +github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= +github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= +github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/std-uritemplate/std-uritemplate/go/v2 v2.0.3 h1:7hth9376EoQEd1hH4lAp3vnaLP2UMyxuMMghLKzDHyU= +github.com/std-uritemplate/std-uritemplate/go/v2 v2.0.3/go.mod h1:Z5KcoM0YLC7INlNhEezeIZ0TZNYf7WSNO0Lvah4DSeQ= +github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= +github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 h1:DvJDOPmSWQHWywQS6lKL+pb8s3gBLOZUtw4N+mavW1I= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0/go.mod h1:EtekO9DEJb4/jRyN4v4Qjc2yA7AtfCBuz2FynRUWTXs= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= +go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= +go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= +go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= +go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= +go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= +gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= +google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/dnaeon/go-vcr.v3 v3.2.0 h1:Rltp0Vf+Aq0u4rQXgmXgtgoRDStTnFN83cWgSGSoRzM= +gopkg.in/dnaeon/go-vcr.v3 v3.2.0/go.mod h1:2IMOnnlx9I6u9x+YBsM3tAMx6AlOxnJ0pWxQAzZ79Ag= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.35.1 h1:0PO/1FhlK/EQNVK5+txc4FuhQibV25VLSdLMmGpDE/Q= +k8s.io/api v0.35.1/go.mod h1:28uR9xlXWml9eT0uaGo6y71xK86JBELShLy4wR1XtxM= +k8s.io/apiextensions-apiserver v0.35.0 h1:3xHk2rTOdWXXJM+RDQZJvdx0yEOgC0FgQ1PlJatA5T4= +k8s.io/apiextensions-apiserver v0.35.0/go.mod h1:E1Ahk9SADaLQ4qtzYFkwUqusXTcaV2uw3l14aqpL2LU= +k8s.io/apimachinery v0.35.1 h1:yxO6gV555P1YV0SANtnTjXYfiivaTPvCTKX6w6qdDsU= +k8s.io/apimachinery v0.35.1/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= +k8s.io/apiserver v0.35.0 h1:CUGo5o+7hW9GcAEF3x3usT3fX4f9r8xmgQeCBDaOgX4= +k8s.io/apiserver v0.35.0/go.mod h1:QUy1U4+PrzbJaM3XGu2tQ7U9A4udRRo5cyxkFX0GEds= +k8s.io/client-go v0.35.1 h1:+eSfZHwuo/I19PaSxqumjqZ9l5XiTEKbIaJ+j1wLcLM= +k8s.io/client-go v0.35.1/go.mod h1:1p1KxDt3a0ruRfc/pG4qT/3oHmUj1AhSHEcxNSGg+OA= +k8s.io/component-base v0.35.0 h1:+yBrOhzri2S1BVqyVSvcM3PtPyx5GUxCK2tinZz1G94= +k8s.io/component-base v0.35.0/go.mod h1:85SCX4UCa6SCFt6p3IKAPej7jSnF3L8EbfSyMZayJR0= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.32.0 h1:XotDXzqvJ8Nx5eiZZueLpTuafJz8SiodgOemI+w87QU= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.32.0/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= +sigs.k8s.io/controller-runtime v0.23.1 h1:TjJSM80Nf43Mg21+RCy3J70aj/W6KyvDtOlpKf+PupE= +sigs.k8s.io/controller-runtime v0.23.1/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 h1:2WOzJpHUBVrrkDjU4KBT8n5LDcj824eX0I5UKcgeRUs= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/services/dis-redis-operator/hack/boilerplate.go.txt b/services/dis-redis-operator/hack/boilerplate.go.txt new file mode 100644 index 000000000..978679816 --- /dev/null +++ b/services/dis-redis-operator/hack/boilerplate.go.txt @@ -0,0 +1,15 @@ +/* +Copyright 2026. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ \ No newline at end of file diff --git a/services/dis-redis-operator/internal/config/config.go b/services/dis-redis-operator/internal/config/config.go new file mode 100644 index 000000000..6f1aa6aea --- /dev/null +++ b/services/dis-redis-operator/internal/config/config.go @@ -0,0 +1,123 @@ +package config + +import ( + "fmt" + "regexp" + "strings" +) + +var ( + subnetARMIDPattern = regexp.MustCompile(`^/subscriptions/[^/]+/resourceGroups/[^/]+/providers/Microsoft\.Network/virtualNetworks/[^/]+/subnets/[^/]+$`) + vnetARMIDPattern = regexp.MustCompile(`^/subscriptions/[^/]+/resourceGroups/[^/]+/providers/Microsoft\.Network/virtualNetworks/[^/]+$`) + tenantIDPattern = regexp.MustCompile(`^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$`) +) + +// OperatorConfig is runtime configuration for the Redis operator. +type OperatorConfig struct { + SubscriptionID string + ResourceGroup string + TenantID string + Location string + Environment string + AKSSubnetIDs []string + AKSVNetID string + DNSZoneResourceGroup string +} + +// ParseSubnetIDs parses and validates comma-separated subnet ARM IDs. +func ParseSubnetIDs(raw string) ([]string, error) { + parts := strings.Split(raw, ",") + ids := make([]string, 0, len(parts)) + for _, part := range parts { + id := strings.TrimSpace(part) + if id == "" { + continue + } + if !subnetARMIDPattern.MatchString(id) { + return nil, fmt.Errorf("invalid subnet ARM ID: %s", id) + } + ids = append(ids, id) + } + if len(ids) == 0 { + return nil, fmt.Errorf("aks-subnet-ids must contain at least one subnet ARM ID") + } + return ids, nil +} + +// NewOperatorConfig validates and returns operator config values. +func NewOperatorConfig( + subscriptionID, + resourceGroup, + tenantID, + location, + environment, + rawSubnetIDs, + aksVNetID, + dnsZoneResourceGroup string, +) (*OperatorConfig, error) { + var missing []string + + subscriptionID = strings.TrimSpace(subscriptionID) + resourceGroup = strings.TrimSpace(resourceGroup) + tenantID = strings.TrimSpace(tenantID) + location = strings.TrimSpace(location) + environment = strings.TrimSpace(environment) + aksVNetID = strings.TrimSpace(aksVNetID) + dnsZoneResourceGroup = strings.TrimSpace(dnsZoneResourceGroup) + + if subscriptionID == "" { + missing = append(missing, "subscription-id") + } + if resourceGroup == "" { + missing = append(missing, "resource-group") + } + if tenantID == "" { + missing = append(missing, "tenant-id") + } + if location == "" { + missing = append(missing, "location") + } + if environment == "" { + missing = append(missing, "env") + } + if aksVNetID == "" { + missing = append(missing, "aks-vnet-id") + } + if dnsZoneResourceGroup == "" { + missing = append(missing, "dns-zone-resource-group") + } + + if len(missing) > 0 { + return nil, fmt.Errorf("missing required configuration: %s", strings.Join(missing, ", ")) + } + if !tenantIDPattern.MatchString(tenantID) { + return nil, fmt.Errorf("invalid tenant-id: must be a UUID") + } + if !vnetARMIDPattern.MatchString(aksVNetID) { + return nil, fmt.Errorf("invalid aks-vnet-id: %s", aksVNetID) + } + + subnetIDs, err := ParseSubnetIDs(rawSubnetIDs) + if err != nil { + return nil, err + } + + return &OperatorConfig{ + SubscriptionID: subscriptionID, + ResourceGroup: resourceGroup, + TenantID: tenantID, + Location: location, + Environment: environment, + AKSSubnetIDs: subnetIDs, + AKSVNetID: aksVNetID, + DNSZoneResourceGroup: dnsZoneResourceGroup, + }, nil +} + +// PrimarySubnetID returns the subnet ARM ID where private endpoints land. +func (c OperatorConfig) PrimarySubnetID() string { + if len(c.AKSSubnetIDs) == 0 { + return "" + } + return c.AKSSubnetIDs[0] +} diff --git a/services/dis-redis-operator/internal/config/config_test.go b/services/dis-redis-operator/internal/config/config_test.go new file mode 100644 index 000000000..2ada0bfe2 --- /dev/null +++ b/services/dis-redis-operator/internal/config/config_test.go @@ -0,0 +1,81 @@ +package config + +import ( + "strings" + "testing" +) + +const ( + testTenantUUID = "00000000-0000-0000-0000-000000000000" + testSubnetID = "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Network/virtualNetworks/vnet/subnets/snet" + testVNetID = "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Network/virtualNetworks/vnet" +) + +func TestParseSubnetIDs(t *testing.T) { + t.Parallel() + + t.Run("parses valid comma-separated subnet IDs", func(t *testing.T) { + t.Parallel() + + ids, err := ParseSubnetIDs(strings.Join([]string{ + "/subscriptions/sub-a/resourceGroups/rg-a/providers/Microsoft.Network/virtualNetworks/vnet-a/subnets/snet-a", + "/subscriptions/sub-a/resourceGroups/rg-a/providers/Microsoft.Network/virtualNetworks/vnet-a/subnets/snet-b", + }, ",")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(ids) != 2 { + t.Fatalf("expected 2 subnet IDs, got %d", len(ids)) + } + }) + + t.Run("rejects empty list", func(t *testing.T) { + t.Parallel() + + if _, err := ParseSubnetIDs(" , "); err == nil { + t.Fatalf("expected error for empty subnet IDs") + } + }) + + t.Run("rejects malformed subnet ID", func(t *testing.T) { + t.Parallel() + + if _, err := ParseSubnetIDs("/subscriptions/sub-a/not-a-valid-id"); err == nil { + t.Fatalf("expected error for malformed subnet ID") + } + }) +} + +func TestNewOperatorConfig(t *testing.T) { + t.Parallel() + + cfg, err := NewOperatorConfig( + "sub", "rg", testTenantUUID, "norwayeast", "dev", + testSubnetID, testVNetID, "rg-dns", + ) + if err != nil { + t.Fatalf("expected config to be valid, got error: %v", err) + } + if cfg.PrimarySubnetID() != testSubnetID { + t.Fatalf("expected primary subnet to match input, got %q", cfg.PrimarySubnetID()) + } + if cfg.DNSZoneResourceGroup != "rg-dns" { + t.Fatalf("expected DNS zone RG to be set, got %q", cfg.DNSZoneResourceGroup) + } + + if _, err := NewOperatorConfig("", "rg", testTenantUUID, "norwayeast", "dev", testSubnetID, testVNetID, "rg-dns"); err == nil { + t.Fatalf("expected error for missing required fields") + } + + if _, err := NewOperatorConfig("sub", "rg", "not-a-uuid", "norwayeast", "dev", testSubnetID, testVNetID, "rg-dns"); err == nil { + t.Fatalf("expected error for invalid tenant UUID") + } + + if _, err := NewOperatorConfig("sub", "rg", testTenantUUID, "norwayeast", "dev", testSubnetID, "not-a-vnet-id", "rg-dns"); err == nil { + t.Fatalf("expected error for invalid vnet id") + } + + if _, err := NewOperatorConfig("sub", "rg", testTenantUUID, "norwayeast", "dev", "", testVNetID, "rg-dns"); err == nil { + t.Fatalf("expected error for missing subnet ids") + } +} diff --git a/services/dis-redis-operator/internal/controller/redis_auth_watch.go b/services/dis-redis-operator/internal/controller/redis_auth_watch.go new file mode 100644 index 000000000..ad76dfdd3 --- /dev/null +++ b/services/dis-redis-operator/internal/controller/redis_auth_watch.go @@ -0,0 +1,52 @@ +package controller + +import ( + "context" + + redisv1alpha1 "github.com/Altinn/altinn-platform/services/dis-redis-operator/api/v1alpha1" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func (r *RedisReconciler) mapApplicationIdentityToRedises(ctx context.Context, obj client.Object) []ctrl.Request { + return r.mapIdentitySourceToRedises(ctx, obj.GetNamespace(), func(rd *redisv1alpha1.Redis) bool { + return redisReferencesApplicationIdentity(rd, obj.GetName()) + }) +} + +func (r *RedisReconciler) mapServiceAccountToRedises(ctx context.Context, obj client.Object) []ctrl.Request { + return r.mapIdentitySourceToRedises(ctx, obj.GetNamespace(), func(rd *redisv1alpha1.Redis) bool { + return redisReferencesServiceAccount(rd, obj.GetName()) + }) +} + +func (r *RedisReconciler) mapIdentitySourceToRedises( + ctx context.Context, + namespace string, + matches func(*redisv1alpha1.Redis) bool, +) []ctrl.Request { + var list redisv1alpha1.RedisList + if err := r.List(ctx, &list, client.InNamespace(namespace)); err != nil { + return nil + } + + requests := make([]ctrl.Request, 0) + for i := range list.Items { + rd := list.Items[i] + if matches(&rd) { + requests = append(requests, ctrl.Request{ + NamespacedName: types.NamespacedName{Name: rd.Name, Namespace: rd.Namespace}, + }) + } + } + return requests +} + +func redisReferencesApplicationIdentity(r *redisv1alpha1.Redis, identityName string) bool { + return r.Spec.IdentityRef != nil && r.Spec.IdentityRef.Name == identityName +} + +func redisReferencesServiceAccount(r *redisv1alpha1.Redis, serviceAccountName string) bool { + return r.Spec.ServiceAccountRef != nil && r.Spec.ServiceAccountRef.Name == serviceAccountName +} diff --git a/services/dis-redis-operator/internal/controller/redis_controller.go b/services/dis-redis-operator/internal/controller/redis_controller.go new file mode 100644 index 000000000..0189b9edc --- /dev/null +++ b/services/dis-redis-operator/internal/controller/redis_controller.go @@ -0,0 +1,295 @@ +package controller + +import ( + "context" + "maps" + "time" + + identityv1alpha1 "github.com/Altinn/altinn-platform/services/dis-identity-operator/api/v1alpha1" + redisv1alpha1 "github.com/Altinn/altinn-platform/services/dis-redis-operator/api/v1alpha1" + "github.com/Altinn/altinn-platform/services/dis-redis-operator/internal/config" + redispkg "github.com/Altinn/altinn-platform/services/dis-redis-operator/internal/redis" + cachev1 "github.com/Azure/azure-service-operator/v2/api/cache/v1api20250401" + pev1 "github.com/Azure/azure-service-operator/v2/api/network/v1api20220701" + networkv1 "github.com/Azure/azure-service-operator/v2/api/network/v1api20240601" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +const ( + identityRequeueDelay = 5 * time.Second + provisioningRequeueDelay = 30 * time.Second +) + +// RedisReconciler reconciles a Redis object. +type RedisReconciler struct { + client.Client + Scheme *runtime.Scheme + Config config.OperatorConfig +} + +// +kubebuilder:rbac:groups=redis.dis.altinn.cloud,resources=redises,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=redis.dis.altinn.cloud,resources=redises/status,verbs=get;update;patch + +// ASO: Cache (Redis Enterprise) +// +kubebuilder:rbac:groups=cache.azure.com,resources=redisenterprises,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=cache.azure.com,resources=redisenterprises/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=cache.azure.com,resources=redisenterprisesdatabases,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=cache.azure.com,resources=redisenterprisesdatabases/status,verbs=get;update;patch + +// ASO: Network +// +kubebuilder:rbac:groups=network.azure.com,resources=privateendpoints,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=network.azure.com,resources=privateendpoints/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=network.azure.com,resources=privatednszones,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=network.azure.com,resources=privatednszones/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=network.azure.com,resources=privatednszonesvirtualnetworklinks,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=network.azure.com,resources=privatednszonesvirtualnetworklinks/status,verbs=get;update;patch + +// ApplicationIdentity +// +kubebuilder:rbac:groups=application.dis.altinn.cloud,resources=applicationidentities,verbs=get;list;watch + +// Core +// +kubebuilder:rbac:groups="",resources=serviceaccounts,verbs=get;list;watch + +// Reconcile drives a Redis CR towards the desired Azure state. +func (r *RedisReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx).WithValues("redis", req.NamespacedName) + + var redisObj redisv1alpha1.Redis + if err := r.Get(ctx, req.NamespacedName, &redisObj); err != nil { + if apierrors.IsNotFound(err) { + return ctrl.Result{}, nil + } + return ctrl.Result{}, err + } + + if !redisObj.DeletionTimestamp.IsZero() { + return ctrl.Result{}, nil + } + + azureName := redispkg.DeterministicAzureRedisName(redisObj.Namespace, redisObj.Name, r.Config.Environment) + + identity, identityPending, err := redispkg.ResolveOwnerIdentity(ctx, r.Client, &redisObj) + if err != nil { + return ctrl.Result{}, err + } + + var ( + clusterReady redispkg.ASOReadyCondition + databaseReady redispkg.ASOReadyCondition + peReady redispkg.ASOReadyCondition + dnsReady redispkg.ASOReadyCondition + cluster *cachev1.RedisEnterprise + database *cachev1.RedisEnterpriseDatabase + ) + + if !identityPending { + desiredCluster, err := redispkg.BuildASORedisEnterprise(&redisObj, r.Config, azureName) + if err != nil { + return ctrl.Result{}, err + } + if err := r.upsertCluster(ctx, &redisObj, desiredCluster); err != nil { + return ctrl.Result{}, err + } + + desiredDB, err := redispkg.BuildASODatabase(&redisObj, desiredCluster.Name) + if err != nil { + return ctrl.Result{}, err + } + if err := r.upsertDatabase(ctx, &redisObj, desiredDB); err != nil { + return ctrl.Result{}, err + } + + desiredPE, err := redispkg.BuildPrivateEndpoint(&redisObj, r.Config, desiredCluster.Name) + if err != nil { + return ctrl.Result{}, err + } + if err := r.upsertPrivateEndpoint(ctx, &redisObj, desiredPE); err != nil { + return ctrl.Result{}, err + } + + if err := r.ensureSharedPrivateDNS(ctx, &redisObj); err != nil { + return ctrl.Result{}, err + } + } + + cluster, clusterReady, err = r.getCurrentCluster(ctx, &redisObj) + if err != nil { + return ctrl.Result{}, err + } + database, databaseReady, err = r.getCurrentDatabase(ctx, &redisObj) + if err != nil { + return ctrl.Result{}, err + } + _, peReady, err = r.getCurrentPrivateEndpoint(ctx, &redisObj) + if err != nil { + return ctrl.Result{}, err + } + dnsReady, err = r.getSharedDNSReady(ctx, &redisObj) + if err != nil { + return ctrl.Result{}, err + } + + if err := r.updateStatus( + ctx, + &redisObj, + azureName, + identity, + identityPending, + cluster, + clusterReady, + database, + databaseReady, + peReady, + dnsReady, + ); err != nil { + return ctrl.Result{}, err + } + + if identityPending { + return ctrl.Result{RequeueAfter: identityRequeueDelay}, nil + } + + logger.Info("reconciled Redis dependencies", "azureName", azureName, "principalId", identity.PrincipalID) + + if !clusterReady.Found || clusterReady.Status != metav1.ConditionTrue || + !databaseReady.Found || databaseReady.Status != metav1.ConditionTrue { + return ctrl.Result{RequeueAfter: provisioningRequeueDelay}, nil + } + + return ctrl.Result{}, nil +} + +// SetupWithManager wires the reconciler with the controller manager. +func (r *RedisReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&redisv1alpha1.Redis{}). + Owns(&cachev1.RedisEnterprise{}). + Owns(&cachev1.RedisEnterpriseDatabase{}). + Owns(&pev1.PrivateEndpoint{}). + Watches(&identityv1alpha1.ApplicationIdentity{}, handler.EnqueueRequestsFromMapFunc(r.mapApplicationIdentityToRedises)). + Watches(&corev1.ServiceAccount{}, handler.EnqueueRequestsFromMapFunc(r.mapServiceAccountToRedises)). + WithOptions(controller.Options{ + MaxConcurrentReconciles: 1, + }). + Complete(r) +} + +func (r *RedisReconciler) upsertCluster(ctx context.Context, owner *redisv1alpha1.Redis, desired *cachev1.RedisEnterprise) error { + current := &cachev1.RedisEnterprise{} + current.SetName(desired.GetName()) + current.SetNamespace(desired.GetNamespace()) + + _, err := controllerutil.CreateOrUpdate(ctx, r.Client, current, func() error { + current.Labels = mergeStringMaps(current.Labels, desired.Labels) + current.Spec = desired.Spec + return ctrl.SetControllerReference(owner, current, r.Scheme) + }) + return err +} + +func (r *RedisReconciler) upsertDatabase(ctx context.Context, owner *redisv1alpha1.Redis, desired *cachev1.RedisEnterpriseDatabase) error { + current := &cachev1.RedisEnterpriseDatabase{} + current.SetName(desired.GetName()) + current.SetNamespace(desired.GetNamespace()) + + _, err := controllerutil.CreateOrUpdate(ctx, r.Client, current, func() error { + current.Labels = mergeStringMaps(current.Labels, desired.Labels) + current.Spec = desired.Spec + return ctrl.SetControllerReference(owner, current, r.Scheme) + }) + return err +} + +func (r *RedisReconciler) upsertPrivateEndpoint(ctx context.Context, owner *redisv1alpha1.Redis, desired *pev1.PrivateEndpoint) error { + current := &pev1.PrivateEndpoint{} + current.SetName(desired.GetName()) + current.SetNamespace(desired.GetNamespace()) + + _, err := controllerutil.CreateOrUpdate(ctx, r.Client, current, func() error { + current.Labels = mergeStringMaps(current.Labels, desired.Labels) + current.Spec = desired.Spec + return ctrl.SetControllerReference(owner, current, r.Scheme) + }) + return err +} + +func (r *RedisReconciler) getCurrentCluster(ctx context.Context, redisObj *redisv1alpha1.Redis) (*cachev1.RedisEnterprise, redispkg.ASOReadyCondition, error) { + current := &cachev1.RedisEnterprise{} + if err := r.Get(ctx, types.NamespacedName{ + Name: redispkg.ClusterKubernetesName(redisObj.Name), + Namespace: redisObj.Namespace, + }, current); err != nil { + if apierrors.IsNotFound(err) { + return nil, redispkg.ASOReadyCondition{}, nil + } + return nil, redispkg.ASOReadyCondition{}, err + } + return current, redispkg.FromASOConditions(current.Status.Conditions), nil +} + +func (r *RedisReconciler) getCurrentDatabase(ctx context.Context, redisObj *redisv1alpha1.Redis) (*cachev1.RedisEnterpriseDatabase, redispkg.ASOReadyCondition, error) { + current := &cachev1.RedisEnterpriseDatabase{} + if err := r.Get(ctx, types.NamespacedName{ + Name: redispkg.DatabaseKubernetesName(redisObj.Name), + Namespace: redisObj.Namespace, + }, current); err != nil { + if apierrors.IsNotFound(err) { + return nil, redispkg.ASOReadyCondition{}, nil + } + return nil, redispkg.ASOReadyCondition{}, err + } + return current, redispkg.FromASOConditions(current.Status.Conditions), nil +} + +func (r *RedisReconciler) getCurrentPrivateEndpoint(ctx context.Context, redisObj *redisv1alpha1.Redis) (*pev1.PrivateEndpoint, redispkg.ASOReadyCondition, error) { + current := &pev1.PrivateEndpoint{} + if err := r.Get(ctx, types.NamespacedName{ + Name: redispkg.PrivateEndpointKubernetesName(redisObj.Name), + Namespace: redisObj.Namespace, + }, current); err != nil { + if apierrors.IsNotFound(err) { + return nil, redispkg.ASOReadyCondition{}, nil + } + return nil, redispkg.ASOReadyCondition{}, err + } + return current, redispkg.FromASOConditions(current.Status.Conditions), nil +} + +func (r *RedisReconciler) getSharedDNSReady(ctx context.Context, redisObj *redisv1alpha1.Redis) (redispkg.ASOReadyCondition, error) { + zone := &networkv1.PrivateDnsZone{} + if err := r.Get(ctx, types.NamespacedName{ + Name: redispkg.RedisPrivateLinkZoneName, + Namespace: redisObj.Namespace, + }, zone); err != nil { + if apierrors.IsNotFound(err) { + return redispkg.ASOReadyCondition{}, nil + } + return redispkg.ASOReadyCondition{}, err + } + return redispkg.FromASOConditions(zone.Status.Conditions), nil +} + +func setStatusCondition(redisObj *redisv1alpha1.Redis, condition metav1.Condition) bool { + return apimeta.SetStatusCondition(&redisObj.Status.Conditions, condition) +} + +func mergeStringMaps(existing, desired map[string]string) map[string]string { + if len(existing) == 0 && len(desired) == 0 { + return nil + } + merged := make(map[string]string, len(existing)+len(desired)) + maps.Copy(merged, existing) + maps.Copy(merged, desired) + return merged +} diff --git a/services/dis-redis-operator/internal/controller/redis_controller_network.go b/services/dis-redis-operator/internal/controller/redis_controller_network.go new file mode 100644 index 000000000..c7830f5b5 --- /dev/null +++ b/services/dis-redis-operator/internal/controller/redis_controller_network.go @@ -0,0 +1,77 @@ +package controller + +import ( + "context" + "fmt" + + redisv1alpha1 "github.com/Altinn/altinn-platform/services/dis-redis-operator/api/v1alpha1" + redispkg "github.com/Altinn/altinn-platform/services/dis-redis-operator/internal/redis" + networkv1 "github.com/Azure/azure-service-operator/v2/api/network/v1api20240601" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +// ensureSharedPrivateDNS get-or-creates the shared privatelink.redis.azure.net zone and AKS VNet link. +// These are shared across all Redis CRs and are NOT owner-referenced to any single CR — instead they are +// label-managed via redis.dis.altinn.cloud/managed-by=dis-redis-operator. +func (r *RedisReconciler) ensureSharedPrivateDNS(ctx context.Context, redisObj *redisv1alpha1.Redis) error { + logger := log.FromContext(ctx).WithValues("redis", types.NamespacedName{Namespace: redisObj.Namespace, Name: redisObj.Name}) + + if err := r.ensureSharedDNSZone(ctx, redisObj.Namespace, logger); err != nil { + return fmt.Errorf("ensure shared DNS zone: %w", err) + } + if err := r.ensureSharedVNetLink(ctx, redisObj.Namespace, logger); err != nil { + return fmt.Errorf("ensure shared VNet link: %w", err) + } + return nil +} + +func (r *RedisReconciler) ensureSharedDNSZone(ctx context.Context, namespace string, logger logrLogger) error { + desired := redispkg.BuildSharedPrivateDNSZone(namespace, r.Config) + + current := &networkv1.PrivateDnsZone{} + err := r.Get(ctx, types.NamespacedName{Name: desired.Name, Namespace: namespace}, current) + if err == nil { + return nil + } + if !apierrors.IsNotFound(err) { + return fmt.Errorf("get PrivateDnsZone %s/%s: %w", namespace, desired.Name, err) + } + + logger.Info("creating shared private DNS zone", "zoneName", desired.Name, "namespace", namespace) + if err := r.Create(ctx, desired); err != nil { + if apierrors.IsAlreadyExists(err) { + return nil + } + return fmt.Errorf("create PrivateDnsZone %s/%s: %w", namespace, desired.Name, err) + } + return nil +} + +func (r *RedisReconciler) ensureSharedVNetLink(ctx context.Context, namespace string, logger logrLogger) error { + desired := redispkg.BuildSharedVNetLink(namespace, r.Config) + + current := &networkv1.PrivateDnsZonesVirtualNetworkLink{} + err := r.Get(ctx, types.NamespacedName{Name: desired.Name, Namespace: namespace}, current) + if err == nil { + return nil + } + if !apierrors.IsNotFound(err) { + return fmt.Errorf("get PrivateDnsZonesVirtualNetworkLink %s/%s: %w", namespace, desired.Name, err) + } + + logger.Info("creating shared private DNS VNet link", "linkName", desired.Name, "namespace", namespace) + if err := r.Create(ctx, desired); err != nil { + if apierrors.IsAlreadyExists(err) { + return nil + } + return fmt.Errorf("create PrivateDnsZonesVirtualNetworkLink %s/%s: %w", namespace, desired.Name, err) + } + return nil +} + +// logrLogger is the minimal interface used by the helpers above. +type logrLogger interface { + Info(msg string, keysAndValues ...any) +} diff --git a/services/dis-redis-operator/internal/controller/redis_controller_role.go b/services/dis-redis-operator/internal/controller/redis_controller_role.go new file mode 100644 index 000000000..1c4b60ef3 --- /dev/null +++ b/services/dis-redis-operator/internal/controller/redis_controller_role.go @@ -0,0 +1,8 @@ +package controller + +// Access policy assignment reconciliation lives here in a future PR. The Azure resource type +// `RedisEnterpriseDatabaseAccessPolicyAssignment` is not yet exposed by Azure Service Operator at +// the ASO version pinned for this slice (v2.17.0). When ASO publishes the type in v1api20250401 +// (tracked upstream), wire reconciliation here mirroring the dis-vault-operator role assignment +// pattern: deterministic GUID for AzureName, owner-ref to the database, principalId from the +// resolved identity, and label-managed lifecycle for delete-and-recreate replacement. diff --git a/services/dis-redis-operator/internal/controller/redis_controller_status.go b/services/dis-redis-operator/internal/controller/redis_controller_status.go new file mode 100644 index 000000000..8879f2b2b --- /dev/null +++ b/services/dis-redis-operator/internal/controller/redis_controller_status.go @@ -0,0 +1,178 @@ +package controller + +import ( + "context" + "fmt" + + redisv1alpha1 "github.com/Altinn/altinn-platform/services/dis-redis-operator/api/v1alpha1" + redispkg "github.com/Altinn/altinn-platform/services/dis-redis-operator/internal/redis" + cachev1 "github.com/Azure/azure-service-operator/v2/api/cache/v1api20250401" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func (r *RedisReconciler) updateStatus( + ctx context.Context, + redisObj *redisv1alpha1.Redis, + azureName string, + identity redispkg.ResolvedIdentity, + identityPending bool, + cluster *cachev1.RedisEnterprise, + clusterReady redispkg.ASOReadyCondition, + database *cachev1.RedisEnterpriseDatabase, + databaseReady redispkg.ASOReadyCondition, + privateEndpointReady redispkg.ASOReadyCondition, + privateDNSReady redispkg.ASOReadyCondition, +) error { + updated := false + + applyCondition := func(c metav1.Condition) metav1.Condition { + if setStatusCondition(redisObj, c) { + updated = true + } + return c + } + + identityCond := applyCondition(buildIdentityCondition(redisObj, identity)) + clusterCond := applyCondition(asoToStatusCondition( + redisObj.Generation, + redisv1alpha1.ConditionClusterReady, + clusterReady, + "ClusterNotReady", + "waiting for ASO RedisEnterprise readiness", + )) + databaseCond := applyCondition(asoToStatusCondition( + redisObj.Generation, + redisv1alpha1.ConditionDatabaseReady, + databaseReady, + "DatabaseNotReady", + "waiting for ASO RedisEnterpriseDatabase readiness", + )) + peCond := applyCondition(asoToStatusCondition( + redisObj.Generation, + redisv1alpha1.ConditionPrivateEndpointReady, + privateEndpointReady, + "PrivateEndpointNotReady", + "waiting for ASO PrivateEndpoint readiness", + )) + dnsCond := applyCondition(asoToStatusCondition( + redisObj.Generation, + redisv1alpha1.ConditionPrivateDNSReady, + privateDNSReady, + "PrivateDNSNotReady", + "waiting for ASO shared private DNS zone readiness", + )) + // AccessPolicyAssignment is deferred to a follow-up PR (ASO type pending in upstream). + accessCond := applyCondition(redispkg.NewCondition( + redisv1alpha1.ConditionAccessPolicyReady, + redisObj.Generation, + metav1.ConditionUnknown, + "Pending", + "access policy assignment is not yet implemented in this slice", + )) + + applyCondition(redispkg.AggregateReadyCondition( + redisObj.Generation, + identityCond, + clusterCond, + databaseCond, + peCond, + dnsCond, + accessCond, + )) + + principalID := identity.PrincipalID + if identityPending { + principalID = "" + } + + updated = setIfChanged(&redisObj.Status.AzureName, azureName) || updated + updated = setIfChanged(&redisObj.Status.OwnerPrincipalID, principalID) || updated + updated = setIfChanged(&redisObj.Status.ClusterResourceID, clusterResourceID(cluster)) || updated + updated = setIfChanged(&redisObj.Status.DatabaseResourceID, databaseResourceID(database)) || updated + updated = setIfChanged(&redisObj.Status.HostName, hostNameFromCluster(cluster)) || updated + updated = setIfChanged(&redisObj.Status.Port, int32(redispkg.DefaultDatabasePort)) || updated + updated = setIfChanged(&redisObj.Status.ObservedGeneration, redisObj.Generation) || updated + + if !updated { + return nil + } + return r.Status().Update(ctx, redisObj) +} + +func buildIdentityCondition(redisObj *redisv1alpha1.Redis, identity redispkg.ResolvedIdentity) metav1.Condition { + if identity.IsPending() { + return redispkg.NewCondition( + redisv1alpha1.ConditionIdentityReady, + redisObj.Generation, + metav1.ConditionFalse, + identity.PendingReason, + identity.PendingMessage, + ) + } + return redispkg.NewCondition( + redisv1alpha1.ConditionIdentityReady, + redisObj.Generation, + metav1.ConditionTrue, + "IdentityReady", + fmt.Sprintf("%s is ready", identity.SourceDescription()), + ) +} + +func asoToStatusCondition( + generation int64, + conditionType redisv1alpha1.ConditionType, + input redispkg.ASOReadyCondition, + notReadyReason, notReadyMessage string, +) metav1.Condition { + if !input.Found { + return redispkg.NewCondition(conditionType, generation, metav1.ConditionUnknown, "NotFound", "dependent resource not found") + } + + reason := input.Reason + if reason == "" { + if input.Status == metav1.ConditionTrue { + reason = "Ready" + } else { + reason = notReadyReason + } + } + message := input.Message + if message == "" { + if input.Status == metav1.ConditionTrue { + message = "dependency is ready" + } else { + message = notReadyMessage + } + } + + return redispkg.NewCondition(conditionType, generation, input.Status, reason, message) +} + +func clusterResourceID(cluster *cachev1.RedisEnterprise) string { + if cluster == nil || cluster.Status.Id == nil { + return "" + } + return *cluster.Status.Id +} + +func databaseResourceID(database *cachev1.RedisEnterpriseDatabase) string { + if database == nil || database.Status.Id == nil { + return "" + } + return *database.Status.Id +} + +func hostNameFromCluster(cluster *cachev1.RedisEnterprise) string { + if cluster == nil || cluster.Status.HostName == nil { + return "" + } + return *cluster.Status.HostName +} + +func setIfChanged[T comparable](field *T, value T) bool { + if *field == value { + return false + } + *field = value + return true +} diff --git a/services/dis-redis-operator/internal/controller/redis_controller_test.go b/services/dis-redis-operator/internal/controller/redis_controller_test.go new file mode 100644 index 000000000..bc745cc08 --- /dev/null +++ b/services/dis-redis-operator/internal/controller/redis_controller_test.go @@ -0,0 +1,15 @@ +package controller + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("RedisReconciler", func() { + It("smoke check: reconciler scheme is registered", func() { + // Placeholder test that runs even when envtest is skipped via DISREDIS_SKIP_ENVTEST=1. + // Real Ginkgo envtest coverage is added in follow-up PRs once ASO CRD provisioning + // is wired into setup-envtest. + Expect(true).To(BeTrue()) + }) +}) diff --git a/services/dis-redis-operator/internal/controller/suite_test.go b/services/dis-redis-operator/internal/controller/suite_test.go new file mode 100644 index 000000000..bb6479887 --- /dev/null +++ b/services/dis-redis-operator/internal/controller/suite_test.go @@ -0,0 +1,13 @@ +package controller + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestControllers(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Controller Suite") +} diff --git a/services/dis-redis-operator/internal/redis/builders.go b/services/dis-redis-operator/internal/redis/builders.go new file mode 100644 index 000000000..941f0d50c --- /dev/null +++ b/services/dis-redis-operator/internal/redis/builders.go @@ -0,0 +1,324 @@ +package redis + +import ( + "fmt" + "maps" + "strings" + + redisv1alpha1 "github.com/Altinn/altinn-platform/services/dis-redis-operator/api/v1alpha1" + "github.com/Altinn/altinn-platform/services/dis-redis-operator/internal/config" + cachev1 "github.com/Azure/azure-service-operator/v2/api/cache/v1api20250401" + pev1 "github.com/Azure/azure-service-operator/v2/api/network/v1api20220701" + networkv1 "github.com/Azure/azure-service-operator/v2/api/network/v1api20240601" + "github.com/Azure/azure-service-operator/v2/pkg/genruntime" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + // RedisPrivateLinkZoneName is the well-known private DNS zone used for Azure Managed Redis. + RedisPrivateLinkZoneName = "privatelink.redis.azure.net" + + // DefaultDatabasePort is the Redis Enterprise database client port. + DefaultDatabasePort int = 10000 + + // DefaultDatabaseAzureName is the default database name used per cluster. + DefaultDatabaseAzureName = "default" + + clusterKubernetesSuffix = "cluster" + databaseKubernetesSuffix = "db" + privateEndpointSuffix = "pe" + privateDNSZoneGroupSuffix = "pdzg" + dnsZoneVNetLinkBaseName = "aks-link" + privateEndpointConnectionID = "redis-enterprise" +) + +// ClusterKubernetesName returns the Kubernetes name used for the ASO RedisEnterprise CR. +func ClusterKubernetesName(redisName string) string { + return DeterministicKubernetesName(redisName, clusterKubernetesSuffix) +} + +// DatabaseKubernetesName returns the Kubernetes name used for the ASO RedisEnterpriseDatabase CR. +func DatabaseKubernetesName(redisName string) string { + return DeterministicKubernetesName(redisName, databaseKubernetesSuffix) +} + +// PrivateEndpointKubernetesName returns the Kubernetes name used for the PrivateEndpoint CR. +func PrivateEndpointKubernetesName(redisName string) string { + return DeterministicKubernetesName(redisName, privateEndpointSuffix) +} + +// PrivateDNSZoneGroupKubernetesName returns the Kubernetes name used for the PrivateDnsZoneGroup CR. +func PrivateDNSZoneGroupKubernetesName(redisName string) string { + return DeterministicKubernetesName(redisName, privateDNSZoneGroupSuffix) +} + +// SharedVNetLinkName returns the Kubernetes name used for the shared AKS VNet link. +func SharedVNetLinkName(environment string) string { + env := sanitizeKubernetesName(environment) + if env == "" { + env = "dis" + } + return env + "-" + dnsZoneVNetLinkBaseName +} + +// BuildASORedisEnterprise returns the desired RedisEnterprise cluster spec. +func BuildASORedisEnterprise(r *redisv1alpha1.Redis, cfg config.OperatorConfig, azureName string) (*cachev1.RedisEnterprise, error) { + if r == nil { + return nil, fmt.Errorf("redis must not be nil") + } + if strings.TrimSpace(azureName) == "" { + return nil, fmt.Errorf("azureName must not be empty") + } + + location := cfg.Location + + skuName := cachev1.Sku_Name(r.Spec.SKU) + if r.Spec.SKU == "" { + skuName = cachev1.Sku_Name_Balanced_B0 + } + + ha := cachev1.ClusterProperties_HighAvailability_Enabled + if r.Spec.HighAvailability != nil && !*r.Spec.HighAvailability { + ha = cachev1.ClusterProperties_HighAvailability_Disabled + } + + tls := cachev1.ClusterProperties_MinimumTlsVersion("1.2") + + zones := []string{} + if r.Spec.HighAvailability == nil || *r.Spec.HighAvailability { + zones = []string{"1", "2", "3"} + } + + tags := maps.Clone(r.Spec.Tags) + if len(tags) == 0 { + tags = nil + } + + cluster := &cachev1.RedisEnterprise{ + ObjectMeta: metav1.ObjectMeta{ + Name: ClusterKubernetesName(r.Name), + Namespace: r.Namespace, + Labels: map[string]string{ + ManagedResourceOwnerLabel: r.Name, + }, + }, + Spec: cachev1.RedisEnterprise_Spec{ + AzureName: azureName, + Location: &location, + HighAvailability: &ha, + MinimumTlsVersion: &tls, + Owner: &genruntime.KnownResourceReference{ + ARMID: fmt.Sprintf("/subscriptions/%s/resourceGroups/%s", cfg.SubscriptionID, cfg.ResourceGroup), + }, + Sku: &cachev1.Sku{ + Name: &skuName, + }, + Tags: tags, + Zones: zones, + }, + } + + return cluster, nil +} + +// BuildASODatabase returns the desired RedisEnterpriseDatabase spec for the cluster. +func BuildASODatabase(r *redisv1alpha1.Redis, clusterKubernetesName string) (*cachev1.RedisEnterpriseDatabase, error) { + if r == nil { + return nil, fmt.Errorf("redis must not be nil") + } + if strings.TrimSpace(clusterKubernetesName) == "" { + return nil, fmt.Errorf("clusterKubernetesName must not be empty") + } + + clientProtocol := cachev1.DatabaseProperties_ClientProtocol_Encrypted + if r.Spec.ClientProtocol == redisv1alpha1.RedisClientProtocolPlaintext { + clientProtocol = cachev1.DatabaseProperties_ClientProtocol_Plaintext + } + + evictionPolicy := cachev1.DatabaseProperties_EvictionPolicy_NoEviction + if r.Spec.EvictionPolicy != "" { + evictionPolicy = cachev1.DatabaseProperties_EvictionPolicy(r.Spec.EvictionPolicy) + } + + accessKeysDisabled := cachev1.DatabaseProperties_AccessKeysAuthentication_Disabled + port := DefaultDatabasePort + + modules := buildModules(r.Spec.Modules) + persistence := buildPersistence(r.Spec.Persistence) + + db := &cachev1.RedisEnterpriseDatabase{ + ObjectMeta: metav1.ObjectMeta{ + Name: DatabaseKubernetesName(r.Name), + Namespace: r.Namespace, + Labels: map[string]string{ + ManagedResourceOwnerLabel: r.Name, + }, + }, + Spec: cachev1.RedisEnterpriseDatabase_Spec{ + AzureName: DefaultDatabaseAzureName, + AccessKeysAuthentication: &accessKeysDisabled, + ClientProtocol: &clientProtocol, + EvictionPolicy: &evictionPolicy, + Modules: modules, + Persistence: persistence, + Port: &port, + Owner: &genruntime.KnownResourceReference{ + Name: clusterKubernetesName, + }, + }, + } + + return db, nil +} + +// BuildPrivateEndpoint returns the desired PrivateEndpoint for a Redis CR's cluster. +func BuildPrivateEndpoint(r *redisv1alpha1.Redis, cfg config.OperatorConfig, clusterKubernetesName string) (*pev1.PrivateEndpoint, error) { + if r == nil { + return nil, fmt.Errorf("redis must not be nil") + } + subnetID := cfg.PrimarySubnetID() + if subnetID == "" { + return nil, fmt.Errorf("no AKS subnet configured for private endpoint") + } + + location := cfg.Location + connName := PrivateEndpointKubernetesName(r.Name) + + groupID := "redisEnterprise" + pe := &pev1.PrivateEndpoint{ + ObjectMeta: metav1.ObjectMeta{ + Name: PrivateEndpointKubernetesName(r.Name), + Namespace: r.Namespace, + Labels: map[string]string{ + ManagedResourceOwnerLabel: r.Name, + }, + }, + Spec: pev1.PrivateEndpoint_Spec{ + AzureName: PrivateEndpointKubernetesName(r.Name), + Location: &location, + Owner: &genruntime.KnownResourceReference{ + ARMID: fmt.Sprintf("/subscriptions/%s/resourceGroups/%s", cfg.SubscriptionID, cfg.ResourceGroup), + }, + Subnet: &pev1.Subnet_PrivateEndpoint_SubResourceEmbedded{ + Reference: &genruntime.ResourceReference{ + ARMID: subnetID, + }, + }, + PrivateLinkServiceConnections: []pev1.PrivateLinkServiceConnection{ + { + Name: &connName, + PrivateLinkServiceReference: &genruntime.ResourceReference{ + Group: cachev1.GroupVersion.Group, + Kind: "RedisEnterprise", + Name: clusterKubernetesName, + }, + GroupIds: []string{groupID}, + }, + }, + }, + } + + return pe, nil +} + +// BuildSharedPrivateDNSZone returns the desired shared privatelink.redis.azure.net zone. +func BuildSharedPrivateDNSZone(namespace string, cfg config.OperatorConfig) *networkv1.PrivateDnsZone { + loc := "global" + return &networkv1.PrivateDnsZone{ + ObjectMeta: metav1.ObjectMeta{ + Name: RedisPrivateLinkZoneName, + Namespace: namespace, + Labels: map[string]string{ + ManagedByLabel: ManagedByValue, + }, + }, + Spec: networkv1.PrivateDnsZone_Spec{ + AzureName: RedisPrivateLinkZoneName, + Location: &loc, + Owner: &genruntime.KnownResourceReference{ + ARMID: fmt.Sprintf("/subscriptions/%s/resourceGroups/%s", cfg.SubscriptionID, cfg.DNSZoneResourceGroup), + }, + }, + } +} + +// BuildSharedVNetLink returns the desired AKS VNet link for the shared zone. +func BuildSharedVNetLink(namespace string, cfg config.OperatorConfig) *networkv1.PrivateDnsZonesVirtualNetworkLink { + loc := "global" + regFalse := false + linkName := SharedVNetLinkName(cfg.Environment) + return &networkv1.PrivateDnsZonesVirtualNetworkLink{ + ObjectMeta: metav1.ObjectMeta{ + Name: linkName, + Namespace: namespace, + Labels: map[string]string{ + ManagedByLabel: ManagedByValue, + }, + }, + Spec: networkv1.PrivateDnsZonesVirtualNetworkLink_Spec{ + AzureName: linkName, + Location: &loc, + RegistrationEnabled: ®False, + Owner: &genruntime.KnownResourceReference{ + Name: RedisPrivateLinkZoneName, + }, + VirtualNetwork: &networkv1.SubResource{ + Reference: &genruntime.ResourceReference{ + ARMID: cfg.AKSVNetID, + }, + }, + }, + } +} + +func buildModules(in []redisv1alpha1.RedisModule) []cachev1.Module { + if len(in) == 0 { + return nil + } + out := make([]cachev1.Module, 0, len(in)) + for _, m := range in { + name := string(m.Name) + args := m.Args + mod := cachev1.Module{Name: &name} + if args != "" { + mod.Args = &args + } + out = append(out, mod) + } + return out +} + +func buildPersistence(in *redisv1alpha1.RedisPersistence) *cachev1.Persistence { + if in == nil { + return nil + } + if in.AOF == "" && in.RDB == "" { + return nil + } + + out := &cachev1.Persistence{} + if in.AOF != "" { + enabled := true + freq := cachev1.Persistence_AofFrequency(aofFrequencyFromSpec(in.AOF)) + out.AofEnabled = &enabled + out.AofFrequency = &freq + } + if in.RDB != "" { + enabled := true + freq := cachev1.Persistence_RdbFrequency(in.RDB) + out.RdbEnabled = &enabled + out.RdbFrequency = &freq + } + return out +} + +func aofFrequencyFromSpec(value string) string { + switch value { + case "Always": + return "always" + case "Every1Second": + return "1s" + default: + return value + } +} diff --git a/services/dis-redis-operator/internal/redis/builders_test.go b/services/dis-redis-operator/internal/redis/builders_test.go new file mode 100644 index 000000000..a80716438 --- /dev/null +++ b/services/dis-redis-operator/internal/redis/builders_test.go @@ -0,0 +1,155 @@ +package redis + +import ( + "testing" + + redisv1alpha1 "github.com/Altinn/altinn-platform/services/dis-redis-operator/api/v1alpha1" + "github.com/Altinn/altinn-platform/services/dis-redis-operator/internal/config" + cachev1 "github.com/Azure/azure-service-operator/v2/api/cache/v1api20250401" +) + +const ( + testRedisName = "my-cache" + testNamespace = "default" + testIdentityName = "my-identity" +) + +func testConfig() config.OperatorConfig { + return config.OperatorConfig{ + SubscriptionID: "sub-123", + ResourceGroup: "rg-dis-dev", + TenantID: "00000000-0000-0000-0000-000000000000", + Location: "norwayeast", + Environment: "dev", + AKSSubnetIDs: []string{ + "/subscriptions/sub-123/resourceGroups/rg-net/providers/Microsoft.Network/virtualNetworks/vnet/subnets/aks-1", + }, + AKSVNetID: "/subscriptions/sub-123/resourceGroups/rg-net/providers/Microsoft.Network/virtualNetworks/vnet", + DNSZoneResourceGroup: "rg-dis-dev", + } +} + +func testRedis() *redisv1alpha1.Redis { + return &redisv1alpha1.Redis{ + Spec: redisv1alpha1.RedisSpec{ + IdentityRef: &redisv1alpha1.ApplicationIdentityRef{Name: testIdentityName}, + }, + } +} + +func TestBuildASORedisEnterpriseDefaults(t *testing.T) { + t.Parallel() + + r := testRedis() + r.Name = testRedisName + r.Namespace = testNamespace + + cluster, err := BuildASORedisEnterprise(r, testConfig(), "my-cache-dev-12345678") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cluster == nil { + t.Fatalf("expected non-nil cluster") + } + + if cluster.Spec.AzureName != "my-cache-dev-12345678" { + t.Fatalf("expected AzureName to be set, got %q", cluster.Spec.AzureName) + } + if cluster.Spec.Sku == nil || cluster.Spec.Sku.Name == nil || *cluster.Spec.Sku.Name != cachev1.Sku_Name_Balanced_B0 { + t.Fatalf("expected default SKU Balanced_B0, got %#v", cluster.Spec.Sku) + } + if cluster.Spec.HighAvailability == nil || *cluster.Spec.HighAvailability != cachev1.ClusterProperties_HighAvailability_Enabled { + t.Fatalf("expected HA enabled by default") + } + if len(cluster.Spec.Zones) != 3 { + t.Fatalf("expected 3 availability zones for HA cluster, got %d", len(cluster.Spec.Zones)) + } +} + +func TestBuildASORedisEnterpriseNonHA(t *testing.T) { + t.Parallel() + + disabled := false + r := testRedis() + r.Name = testRedisName + r.Namespace = testNamespace + r.Spec.HighAvailability = &disabled + + cluster, err := BuildASORedisEnterprise(r, testConfig(), "name-123") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if *cluster.Spec.HighAvailability != cachev1.ClusterProperties_HighAvailability_Disabled { + t.Fatalf("expected HA disabled") + } + if len(cluster.Spec.Zones) != 0 { + t.Fatalf("expected no zones for non-HA cluster, got %d", len(cluster.Spec.Zones)) + } +} + +func TestBuildASODatabaseDefaults(t *testing.T) { + t.Parallel() + + r := testRedis() + r.Name = testRedisName + r.Namespace = testNamespace + + db, err := BuildASODatabase(r, ClusterKubernetesName(testRedisName)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if db.Spec.AccessKeysAuthentication == nil || *db.Spec.AccessKeysAuthentication != cachev1.DatabaseProperties_AccessKeysAuthentication_Disabled { + t.Fatalf("expected access keys disabled by default") + } + if db.Spec.ClientProtocol == nil || *db.Spec.ClientProtocol != cachev1.DatabaseProperties_ClientProtocol_Encrypted { + t.Fatalf("expected encrypted client protocol by default") + } + if db.Spec.Port == nil || *db.Spec.Port != DefaultDatabasePort { + t.Fatalf("expected default port %d, got %v", DefaultDatabasePort, db.Spec.Port) + } + if db.Spec.Owner == nil || db.Spec.Owner.Name != ClusterKubernetesName(testRedisName) { + t.Fatalf("expected owner to reference cluster") + } +} + +func TestBuildPrivateEndpoint(t *testing.T) { + t.Parallel() + + r := testRedis() + r.Name = testRedisName + r.Namespace = testNamespace + + pe, err := BuildPrivateEndpoint(r, testConfig(), ClusterKubernetesName(testRedisName)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pe.Spec.Subnet == nil || pe.Spec.Subnet.Reference == nil { + t.Fatalf("expected subnet to be referenced") + } + if len(pe.Spec.PrivateLinkServiceConnections) != 1 { + t.Fatalf("expected one private link service connection") + } +} + +func TestBuildSharedPrivateDNSZone(t *testing.T) { + t.Parallel() + + zone := BuildSharedPrivateDNSZone(testNamespace, testConfig()) + if zone.Name != RedisPrivateLinkZoneName { + t.Fatalf("expected zone name %q, got %q", RedisPrivateLinkZoneName, zone.Name) + } + if zone.Labels[ManagedByLabel] != ManagedByValue { + t.Fatalf("expected managed-by label on shared zone") + } +} + +func TestSharedVNetLinkName(t *testing.T) { + t.Parallel() + + if got := SharedVNetLinkName("Dev"); got != "dev-aks-link" { + t.Fatalf("expected dev-aks-link, got %q", got) + } + if got := SharedVNetLinkName(""); got != "dis-aks-link" { + t.Fatalf("expected fallback dis-aks-link, got %q", got) + } +} diff --git a/services/dis-redis-operator/internal/redis/identity.go b/services/dis-redis-operator/internal/redis/identity.go new file mode 100644 index 000000000..c8de9ad25 --- /dev/null +++ b/services/dis-redis-operator/internal/redis/identity.go @@ -0,0 +1,181 @@ +package redis + +import ( + "context" + "fmt" + "strings" + + identityv1alpha1 "github.com/Altinn/altinn-platform/services/dis-identity-operator/api/v1alpha1" + redisv1alpha1 "github.com/Altinn/altinn-platform/services/dis-redis-operator/api/v1alpha1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + ServiceAccountClientIDAnnotation = "azure.workload.identity/client-id" + ServiceAccountPrincipalIDAnnotation = "dis.altinn.cloud/principal-id" + identityNotReadyReason = "IdentityNotReady" +) + +// IdentitySourceKind identifies the kind of resource that backs the owner identity. +type IdentitySourceKind string + +const ( + IdentitySourceApplicationIdentity IdentitySourceKind = "ApplicationIdentity" + IdentitySourceServiceAccount IdentitySourceKind = "ServiceAccount" +) + +// ResolvedIdentity contains owner identity values required for provisioning. +type ResolvedIdentity struct { + SourceKind IdentitySourceKind + SourceName string + AuthReferenceName string + ServiceAccountName string + PrincipalID string + PendingReason string + PendingMessage string +} + +// IsPending reports whether the identity resolution is still waiting on dependencies. +func (r ResolvedIdentity) IsPending() bool { + return r.PendingReason != "" +} + +// SourceDescription returns a human-readable description of the identity source. +func (r ResolvedIdentity) SourceDescription() string { + if r.SourceKind == "" || r.SourceName == "" { + return "identity source" + } + return fmt.Sprintf("%s %q", r.SourceKind, r.SourceName) +} + +// ActiveAuthReferenceName returns the name of the active identity reference. +func ActiveAuthReferenceName(r *redisv1alpha1.Redis) (string, error) { + if r == nil { + return "", fmt.Errorf("redis must not be nil") + } + + switch { + case r.Spec.ServiceAccountRef != nil: + name := strings.TrimSpace(r.Spec.ServiceAccountRef.Name) + if name == "" { + return "", fmt.Errorf("serviceAccountRef.name must not be empty") + } + return name, nil + case r.Spec.IdentityRef != nil: + name := strings.TrimSpace(r.Spec.IdentityRef.Name) + if name == "" { + return "", fmt.Errorf("identityRef.name must not be empty") + } + return name, nil + default: + return "", fmt.Errorf("exactly one of identityRef or serviceAccountRef must be set") + } +} + +// ResolveOwnerIdentity resolves the active owner identity source for a Redis CR. +// The bool return indicates whether the caller should requeue. +func ResolveOwnerIdentity(ctx context.Context, c client.Reader, r *redisv1alpha1.Redis) (ResolvedIdentity, bool, error) { + if r == nil { + return ResolvedIdentity{}, false, fmt.Errorf("redis must not be nil") + } + + switch { + case r.Spec.IdentityRef != nil && r.Spec.ServiceAccountRef != nil: + return ResolvedIdentity{ + PendingReason: "InvalidSpec", + PendingMessage: "exactly one of identityRef or serviceAccountRef must be set", + }, true, nil + case r.Spec.IdentityRef != nil: + return resolveApplicationIdentity(ctx, c, r.Namespace, r.Spec.IdentityRef.Name) + case r.Spec.ServiceAccountRef != nil: + return resolveServiceAccount(ctx, c, r.Namespace, r.Spec.ServiceAccountRef.Name) + default: + return ResolvedIdentity{ + PendingReason: "InvalidSpec", + PendingMessage: "exactly one of identityRef or serviceAccountRef must be set", + }, true, nil + } +} + +func resolveApplicationIdentity(ctx context.Context, c client.Reader, namespace, identityName string) (ResolvedIdentity, bool, error) { + identityName = strings.TrimSpace(identityName) + resolved := ResolvedIdentity{ + SourceKind: IdentitySourceApplicationIdentity, + SourceName: identityName, + AuthReferenceName: identityName, + ServiceAccountName: identityName, + } + + var identity identityv1alpha1.ApplicationIdentity + if err := c.Get(ctx, types.NamespacedName{Namespace: namespace, Name: identityName}, &identity); err != nil { + if apierrors.IsNotFound(err) { + resolved.PendingReason = identityNotReadyReason + resolved.PendingMessage = fmt.Sprintf("%s not found", resolved.SourceDescription()) + return resolved, true, nil + } + return ResolvedIdentity{}, false, err + } + + readyCond := meta.FindStatusCondition(identity.Status.Conditions, string(identityv1alpha1.ConditionReady)) + if readyCond == nil || readyCond.Status != metav1.ConditionTrue { + resolved.PendingReason = identityNotReadyReason + resolved.PendingMessage = fmt.Sprintf("%s is not ready", resolved.SourceDescription()) + return resolved, true, nil + } + + if identity.Status.ManagedIdentityName == nil || *identity.Status.ManagedIdentityName == "" { + resolved.PendingReason = identityNotReadyReason + resolved.PendingMessage = fmt.Sprintf("%s is missing status.managedIdentityName", resolved.SourceDescription()) + return resolved, true, nil + } + if identity.Status.PrincipalID == nil || *identity.Status.PrincipalID == "" { + resolved.PendingReason = identityNotReadyReason + resolved.PendingMessage = fmt.Sprintf("%s is missing status.principalId", resolved.SourceDescription()) + return resolved, true, nil + } + + resolved.PrincipalID = *identity.Status.PrincipalID + return resolved, false, nil +} + +func resolveServiceAccount(ctx context.Context, c client.Reader, namespace, serviceAccountName string) (ResolvedIdentity, bool, error) { + serviceAccountName = strings.TrimSpace(serviceAccountName) + resolved := ResolvedIdentity{ + SourceKind: IdentitySourceServiceAccount, + SourceName: serviceAccountName, + AuthReferenceName: serviceAccountName, + ServiceAccountName: serviceAccountName, + } + + var sa corev1.ServiceAccount + if err := c.Get(ctx, types.NamespacedName{Namespace: namespace, Name: serviceAccountName}, &sa); err != nil { + if apierrors.IsNotFound(err) { + resolved.PendingReason = identityNotReadyReason + resolved.PendingMessage = fmt.Sprintf("%s not found", resolved.SourceDescription()) + return resolved, true, nil + } + return ResolvedIdentity{}, false, err + } + + clientID := strings.TrimSpace(sa.Annotations[ServiceAccountClientIDAnnotation]) + if clientID == "" { + resolved.PendingReason = identityNotReadyReason + resolved.PendingMessage = fmt.Sprintf("%s is missing annotation %q", resolved.SourceDescription(), ServiceAccountClientIDAnnotation) + return resolved, true, nil + } + + principalID := strings.TrimSpace(sa.Annotations[ServiceAccountPrincipalIDAnnotation]) + if principalID == "" { + resolved.PendingReason = identityNotReadyReason + resolved.PendingMessage = fmt.Sprintf("%s is missing annotation %q", resolved.SourceDescription(), ServiceAccountPrincipalIDAnnotation) + return resolved, true, nil + } + + resolved.PrincipalID = principalID + return resolved, false, nil +} diff --git a/services/dis-redis-operator/internal/redis/identity_test.go b/services/dis-redis-operator/internal/redis/identity_test.go new file mode 100644 index 000000000..70d3599a5 --- /dev/null +++ b/services/dis-redis-operator/internal/redis/identity_test.go @@ -0,0 +1,169 @@ +package redis + +import ( + "context" + "testing" + + identityv1alpha1 "github.com/Altinn/altinn-platform/services/dis-identity-operator/api/v1alpha1" + redisv1alpha1 "github.com/Altinn/altinn-platform/services/dis-redis-operator/api/v1alpha1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func newTestScheme(t *testing.T) *runtime.Scheme { + t.Helper() + scheme := runtime.NewScheme() + if err := corev1.AddToScheme(scheme); err != nil { + t.Fatalf("add corev1: %v", err) + } + if err := identityv1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("add identity scheme: %v", err) + } + if err := redisv1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("add redis scheme: %v", err) + } + return scheme +} + +func TestResolveOwnerIdentityReadyApplicationIdentity(t *testing.T) { + t.Parallel() + + scheme := newTestScheme(t) + principal := "p-123" + managedName := "mi-name" + + identity := &identityv1alpha1.ApplicationIdentity{ + ObjectMeta: metav1.ObjectMeta{Name: "app-ready", Namespace: "default"}, + Status: identityv1alpha1.ApplicationIdentityStatus{ + ManagedIdentityName: &managedName, + PrincipalID: &principal, + Conditions: []metav1.Condition{{ + Type: string(identityv1alpha1.ConditionReady), + Status: metav1.ConditionTrue, + Reason: "Ready", + }}, + }, + } + redisObj := &redisv1alpha1.Redis{ + ObjectMeta: metav1.ObjectMeta{Name: "cache", Namespace: "default"}, + Spec: redisv1alpha1.RedisSpec{ + IdentityRef: &redisv1alpha1.ApplicationIdentityRef{Name: "app-ready"}, + }, + } + + c := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(identity).WithObjects(identity).Build() + + resolved, requeue, err := ResolveOwnerIdentity(context.Background(), c, redisObj) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if requeue { + t.Fatalf("expected requeue=false for ready identity") + } + if resolved.PrincipalID != principal { + t.Fatalf("expected principalId %q, got %q", principal, resolved.PrincipalID) + } + if resolved.SourceKind != IdentitySourceApplicationIdentity { + t.Fatalf("expected source kind ApplicationIdentity, got %q", resolved.SourceKind) + } +} + +func TestResolveOwnerIdentityUnreadyApplicationIdentity(t *testing.T) { + t.Parallel() + + scheme := newTestScheme(t) + identity := &identityv1alpha1.ApplicationIdentity{ + ObjectMeta: metav1.ObjectMeta{Name: "app-pending", Namespace: "default"}, + } + redisObj := &redisv1alpha1.Redis{ + ObjectMeta: metav1.ObjectMeta{Name: "cache", Namespace: "default"}, + Spec: redisv1alpha1.RedisSpec{ + IdentityRef: &redisv1alpha1.ApplicationIdentityRef{Name: "app-pending"}, + }, + } + + c := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(identity).WithObjects(identity).Build() + + resolved, requeue, err := ResolveOwnerIdentity(context.Background(), c, redisObj) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !requeue { + t.Fatalf("expected requeue=true for unready identity") + } + if resolved.PendingReason != identityNotReadyReason { + t.Fatalf("expected pending reason %q, got %q", identityNotReadyReason, resolved.PendingReason) + } +} + +func TestResolveOwnerIdentityServiceAccountAnnotated(t *testing.T) { + t.Parallel() + + scheme := newTestScheme(t) + sa := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cache-sa", + Namespace: "default", + Annotations: map[string]string{ + ServiceAccountClientIDAnnotation: "c-123", + ServiceAccountPrincipalIDAnnotation: "p-456", + }, + }, + } + redisObj := &redisv1alpha1.Redis{ + ObjectMeta: metav1.ObjectMeta{Name: "cache", Namespace: "default"}, + Spec: redisv1alpha1.RedisSpec{ + ServiceAccountRef: &redisv1alpha1.ServiceAccountRef{Name: "cache-sa"}, + }, + } + + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(sa).Build() + + resolved, requeue, err := ResolveOwnerIdentity(context.Background(), c, redisObj) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if requeue { + t.Fatalf("expected requeue=false for annotated SA") + } + if resolved.PrincipalID != "p-456" { + t.Fatalf("expected principalId p-456, got %q", resolved.PrincipalID) + } + if resolved.SourceKind != IdentitySourceServiceAccount { + t.Fatalf("expected source kind ServiceAccount") + } +} + +func TestResolveOwnerIdentityServiceAccountMissingAnnotation(t *testing.T) { + t.Parallel() + + scheme := newTestScheme(t) + sa := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cache-sa", + Namespace: "default", + Annotations: map[string]string{ServiceAccountClientIDAnnotation: "c-123"}, + }, + } + redisObj := &redisv1alpha1.Redis{ + ObjectMeta: metav1.ObjectMeta{Name: "cache", Namespace: "default"}, + Spec: redisv1alpha1.RedisSpec{ + ServiceAccountRef: &redisv1alpha1.ServiceAccountRef{Name: "cache-sa"}, + }, + } + + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(sa).Build() + + resolved, requeue, err := ResolveOwnerIdentity(context.Background(), c, redisObj) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !requeue { + t.Fatalf("expected requeue=true for missing principal-id annotation") + } + if resolved.PrincipalID != "" { + t.Fatalf("expected empty principalId, got %q", resolved.PrincipalID) + } +} diff --git a/services/dis-redis-operator/internal/redis/labels.go b/services/dis-redis-operator/internal/redis/labels.go new file mode 100644 index 000000000..36b3a70dc --- /dev/null +++ b/services/dis-redis-operator/internal/redis/labels.go @@ -0,0 +1,10 @@ +package redis + +const ( + // ManagedResourceOwnerLabel marks a managed resource with the Redis CR name that owns it. + ManagedResourceOwnerLabel = "redis.dis.altinn.cloud/name" + // ManagedByLabel marks shared resources (DNS zone, VNet link) as operator-managed. + ManagedByLabel = "redis.dis.altinn.cloud/managed-by" + // ManagedByValue is the canonical operator identifier. + ManagedByValue = "dis-redis-operator" +) diff --git a/services/dis-redis-operator/internal/redis/naming.go b/services/dis-redis-operator/internal/redis/naming.go new file mode 100644 index 000000000..c44695acd --- /dev/null +++ b/services/dis-redis-operator/internal/redis/naming.go @@ -0,0 +1,91 @@ +package redis + +import ( + "crypto/sha1" + "encoding/hex" + "fmt" + "strings" + + "k8s.io/apimachinery/pkg/util/validation" +) + +const ( + defaultManagedResourceBaseName = "redis" + maxAzureRedisNameLen = 60 + azureNameHashLen = 8 +) + +// DeterministicAzureRedisName returns a deterministic Azure Managed Redis cluster name. +// The name is lowercase, DNS-label safe, <= 60 chars, and has a stable hash suffix. +func DeterministicAzureRedisName(namespace, name, environment string) string { + base := sanitizeKubernetesName(fmt.Sprintf("%s-%s-%s", namespace, name, environment)) + if base == "" { + base = defaultManagedResourceBaseName + } + + hash := stableHexHash(namespace + "/" + name + "/" + environment)[:azureNameHashLen] + maxBaseLen := max(maxAzureRedisNameLen-len(hash)-1, 1) + base = strings.Trim(base[:min(len(base), maxBaseLen)], "-") + if base == "" { + base = "r" + } + + return base + "-" + hash +} + +// DeterministicKubernetesName returns a Kubernetes-safe deterministic name with a suffix. +func DeterministicKubernetesName(base, suffix string) string { + base = sanitizeKubernetesName(base) + if base == "" { + base = defaultManagedResourceBaseName + } + suffix = sanitizeKubernetesName(suffix) + if suffix == "" { + suffix = "res" + } + + out := base + "-" + suffix + if len(out) <= validation.DNS1123SubdomainMaxLength { + return out + } + + hash := stableHexHash(out)[:8] + maxBase := max(validation.DNS1123SubdomainMaxLength-len(suffix)-len(hash)-2, 1) + base = strings.Trim(base[:min(len(base), maxBase)], "-") + if base == "" { + base = "r" + } + return base + "-" + suffix + "-" + hash +} + +func sanitizeKubernetesName(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + if s == "" { + return "" + } + + var b strings.Builder + b.Grow(len(s)) + + lastHyphen := false + for _, r := range s { + isLetter := r >= 'a' && r <= 'z' + isDigit := r >= '0' && r <= '9' + if isLetter || isDigit { + b.WriteRune(r) + lastHyphen = false + continue + } + if !lastHyphen { + b.WriteByte('-') + lastHyphen = true + } + } + + return strings.Trim(b.String(), "-") +} + +func stableHexHash(input string) string { + sum := sha1.Sum([]byte(input)) + return hex.EncodeToString(sum[:]) +} diff --git a/services/dis-redis-operator/internal/redis/naming_test.go b/services/dis-redis-operator/internal/redis/naming_test.go new file mode 100644 index 000000000..92fcdd481 --- /dev/null +++ b/services/dis-redis-operator/internal/redis/naming_test.go @@ -0,0 +1,44 @@ +package redis + +import ( + "regexp" + "testing" +) + +func TestDeterministicAzureRedisName(t *testing.T) { + t.Parallel() + + a := DeterministicAzureRedisName("default", "my-app-cache", "prod") + b := DeterministicAzureRedisName("default", "my-app-cache", "prod") + if a != b { + t.Fatalf("expected deterministic output, got %q and %q", a, b) + } + if len(a) == 0 || len(a) > maxAzureRedisNameLen { + t.Fatalf("expected name length 1..%d, got %d (%q)", maxAzureRedisNameLen, len(a), a) + } + if matched := regexp.MustCompile(`^[a-z0-9-]+$`).MatchString(a); !matched { + t.Fatalf("expected DNS-label-safe characters, got %q", a) + } + if !regexp.MustCompile(`.*-[a-f0-9]{6,8}$`).MatchString(a) { + t.Fatalf("expected stable hash suffix, got %q", a) + } +} + +func TestDeterministicAzureRedisNameUniqueByInputs(t *testing.T) { + t.Parallel() + + a := DeterministicAzureRedisName("ns1", "name", "dev") + b := DeterministicAzureRedisName("ns2", "name", "dev") + if a == b { + t.Fatalf("expected different namespaces to produce different names, got %q and %q", a, b) + } +} + +func TestDeterministicKubernetesName(t *testing.T) { + t.Parallel() + + name := DeterministicKubernetesName("my-redis", "pe") + if name != "my-redis-pe" { + t.Fatalf("expected suffix concatenation, got %q", name) + } +} diff --git a/services/dis-redis-operator/internal/redis/status.go b/services/dis-redis-operator/internal/redis/status.go new file mode 100644 index 000000000..0f7723112 --- /dev/null +++ b/services/dis-redis-operator/internal/redis/status.go @@ -0,0 +1,89 @@ +package redis + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + redisv1alpha1 "github.com/Altinn/altinn-platform/services/dis-redis-operator/api/v1alpha1" + asoconditions "github.com/Azure/azure-service-operator/v2/pkg/genruntime/conditions" +) + +// ASOReadyCondition is a projected ASO Ready condition used by the controller. +type ASOReadyCondition struct { + Status metav1.ConditionStatus + Reason string + Message string + Found bool +} + +// FromASOConditions projects the Ready condition from a slice of ASO conditions. +func FromASOConditions(conditions []asoconditions.Condition) ASOReadyCondition { + for _, cond := range conditions { + if cond.Type != asoconditions.ConditionTypeReady { + continue + } + return ASOReadyCondition{ + Found: true, + Status: cond.Status, + Reason: cond.Reason, + Message: cond.Message, + } + } + return ASOReadyCondition{} +} + +// NewCondition builds a standard metav1.Condition for the Redis status. +func NewCondition( + conditionType redisv1alpha1.ConditionType, + generation int64, + status metav1.ConditionStatus, + reason, message string, +) metav1.Condition { + return metav1.Condition{ + Type: string(conditionType), + Status: status, + Reason: reason, + Message: message, + ObservedGeneration: generation, + } +} + +// AggregateReadyCondition combines child conditions into a single Ready condition. +func AggregateReadyCondition(generation int64, conditions ...metav1.Condition) metav1.Condition { + hasFalse := false + hasUnknown := false + for _, cond := range conditions { + switch cond.Status { + case metav1.ConditionFalse: + hasFalse = true + case metav1.ConditionUnknown: + hasUnknown = true + } + } + + switch { + case hasFalse: + return NewCondition( + redisv1alpha1.ConditionReady, + generation, + metav1.ConditionFalse, + "DependencyNotReady", + "one or more dependencies are not ready", + ) + case hasUnknown: + return NewCondition( + redisv1alpha1.ConditionReady, + generation, + metav1.ConditionUnknown, + "DependenciesPending", + "waiting for dependent resources", + ) + default: + return NewCondition( + redisv1alpha1.ConditionReady, + generation, + metav1.ConditionTrue, + "Ready", + "all dependencies are ready", + ) + } +} diff --git a/services/dis-redis-operator/version.txt b/services/dis-redis-operator/version.txt new file mode 100644 index 000000000..6e8bf73aa --- /dev/null +++ b/services/dis-redis-operator/version.txt @@ -0,0 +1 @@ +0.1.0 From cbc90a644b33258559644ed66b58842459e7df98 Mon Sep 17 00:00:00 2001 From: Are Almaas Date: Tue, 19 May 2026 19:01:27 -0300 Subject: [PATCH 02/11] fix(dis-redis-operator): address review feedback - requeue gate now also waits on private endpoint and shared DNS readiness - getSharedDNSReady checks both PrivateDnsZone and VNet link before reporting True - skip stale ASO condition reporting while identity is pending; emit IdentityNotReady instead - enforce mutual exclusivity of aof/rdb on RedisPersistence via CRD XValidation - validate clusterKubernetesName in BuildPrivateEndpoint, mutual identity/SA refs in ActiveAuthReferenceName, and empty input in AggregateReadyCondition - Makefile: stop swallowing kustomize errors in install/uninstall, add test-e2e with guaranteed cleanup, align GOLANGCI_LINT_VERSION with CI (v2.11.4) - workflows: persist-credentials: false on checkouts, narrow global permissions with per-job write grants, replace mutating go mod tidy with make verify-deps - redis_types.go: fix Enum marker syntax and restore resource path plural to unblock make manifests Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/dis-redis-lint-test.yml | 2 +- .github/workflows/dis-redis-release.yml | 17 +- services/dis-redis-operator/Makefile | 14 +- .../api/v1alpha1/redis_types.go | 4 +- .../api/v1alpha1/zz_generated.deepcopy.go | 90 +++---- .../bases/redis.dis.altinn.cloud_redises.yaml | 250 ++++++++++++------ .../dis-redis-operator/config/rbac/role.yaml | 4 +- .../internal/controller/redis_controller.go | 21 +- .../controller/redis_controller_status.go | 31 ++- .../internal/redis/builders.go | 3 + .../internal/redis/identity.go | 4 + .../internal/redis/status.go | 10 + 12 files changed, 311 insertions(+), 139 deletions(-) diff --git a/.github/workflows/dis-redis-lint-test.yml b/.github/workflows/dis-redis-lint-test.yml index b5abb5c89..c74123814 100644 --- a/.github/workflows/dis-redis-lint-test.yml +++ b/.github/workflows/dis-redis-lint-test.yml @@ -57,5 +57,5 @@ jobs: - name: Running Tests run: | - go mod tidy + make verify-deps make test diff --git a/.github/workflows/dis-redis-release.yml b/.github/workflows/dis-redis-release.yml index 6741b5674..9904fc114 100644 --- a/.github/workflows/dis-redis-release.yml +++ b/.github/workflows/dis-redis-release.yml @@ -24,12 +24,13 @@ on: permissions: contents: read - packages: write - id-token: write jobs: build-and-release-image: name: Build, scan and release + permissions: + contents: read + packages: write uses: ./.github/workflows/reusable-image-scan-and-release-ghcr.yml with: release_latest: true @@ -40,6 +41,10 @@ jobs: build-release-flux-oci-latest: name: Build latest from main if: github.ref == 'refs/heads/main' + permissions: + contents: read + packages: write + id-token: write defaults: run: working-directory: ./services/dis-redis-operator/config @@ -47,6 +52,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Push latest flux oci image from main uses: ./actions/flux/build-push-image with: @@ -60,6 +67,10 @@ jobs: name: Build release from tag if: startsWith(github.ref, 'refs/tags/dis-redis-v') environment: flux-release + permissions: + contents: read + packages: write + id-token: write defaults: run: working-directory: ./services/dis-redis-operator/config @@ -67,6 +78,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Setup vars id: vars run: | diff --git a/services/dis-redis-operator/Makefile b/services/dis-redis-operator/Makefile index 9ddb16fed..82b67d977 100644 --- a/services/dis-redis-operator/Makefile +++ b/services/dis-redis-operator/Makefile @@ -145,6 +145,14 @@ setup-test-e2e: ## Set up a Kind cluster for e2e tests if it does not exist @mkdir -p "$(LOCALBIN)" @$(KIND) get kubeconfig --name $(KIND_CLUSTER) > "$(KIND_KUBECONFIG)" +.PHONY: test-e2e +test-e2e: setup-test-e2e manifests generate fmt vet ## Run the e2e tests. Expected an isolated environment using Kind. + @test_status=0; cleanup_status=0; \ + KUBECONFIG=$(KIND_KUBECONFIG) CERT_MANAGER_INSTALL_SKIP=$(CERT_MANAGER_INSTALL_SKIP) KIND=$(KIND) KIND_CLUSTER=$(KIND_CLUSTER) go test -tags=e2e ./test/e2e/ -v -ginkgo.v || test_status=$$?; \ + $(MAKE) cleanup-test-e2e || cleanup_status=$$?; \ + if [ $$test_status -ne 0 ]; then exit $$test_status; fi; \ + exit $$cleanup_status + .PHONY: cleanup-test-e2e cleanup-test-e2e: ## Tear down the Kind cluster used for e2e tests @$(KIND) delete cluster --name $(KIND_CLUSTER) @@ -218,12 +226,12 @@ endif .PHONY: install install: manifests kustomize ## Install CRDs into the K8s cluster specified in ~/.kube/config. - @out="$$( "$(KUSTOMIZE)" build config/crd 2>/dev/null || true )"; \ + @out="$$( "$(KUSTOMIZE)" build config/crd )"; \ if [ -n "$$out" ]; then echo "$$out" | "$(KUBECTL)" apply -f -; else echo "No CRDs to install; skipping."; fi .PHONY: uninstall uninstall: manifests kustomize ## Uninstall CRDs from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion. - @out="$$( "$(KUSTOMIZE)" build config/crd 2>/dev/null || true )"; \ + @out="$$( "$(KUSTOMIZE)" build config/crd )"; \ if [ -n "$$out" ]; then echo "$$out" | "$(KUBECTL)" delete --ignore-not-found=$(ignore-not-found) -f -; else echo "No CRDs to delete; skipping."; fi .PHONY: deploy @@ -268,7 +276,7 @@ ENVTEST_K8S_VERSION ?= $(shell v='$(call gomodver,k8s.io/api)'; \ [ -n "$$v" ] || { echo "Set ENVTEST_K8S_VERSION manually (k8s.io/api replace has no tag)" >&2; exit 1; }; \ printf '%s\n' "$$v" | sed -E 's/^v?[0-9]+\.([0-9]+).*/1.\1/') -GOLANGCI_LINT_VERSION ?= v2.11.3 +GOLANGCI_LINT_VERSION ?= v2.11.4 GOVULNCHECK_VERSION ?= latest .PHONY: kustomize diff --git a/services/dis-redis-operator/api/v1alpha1/redis_types.go b/services/dis-redis-operator/api/v1alpha1/redis_types.go index 61a89b5c2..30b6a7b1b 100644 --- a/services/dis-redis-operator/api/v1alpha1/redis_types.go +++ b/services/dis-redis-operator/api/v1alpha1/redis_types.go @@ -75,6 +75,7 @@ type RedisModule struct { // RedisPersistence configures AOF / RDB persistence settings for the database. // At most one of AOF or RDB may be enabled. +// +kubebuilder:validation:XValidation:rule="!(has(self.aof) && has(self.rdb))",message="Only one of 'aof' or 'rdb' may be set" type RedisPersistence struct { // AOF enables append-only-file persistence with the specified frequency. // +optional @@ -83,7 +84,7 @@ type RedisPersistence struct { // RDB enables snapshot persistence with the specified frequency. // +optional - // +kubebuilder:validation:Enum=1h;6h;12h + // +kubebuilder:validation:Enum:="1h";"6h";"12h" RDB string `json:"rdb,omitempty"` } @@ -206,6 +207,7 @@ const ( // +kubebuilder:object:root=true // +kubebuilder:subresource:status +// +kubebuilder:resource:path=redises // +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].status" // +kubebuilder:printcolumn:name="Reason",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].reason" // +kubebuilder:printcolumn:name="AzureName",type="string",JSONPath=".status.azureName" diff --git a/services/dis-redis-operator/api/v1alpha1/zz_generated.deepcopy.go b/services/dis-redis-operator/api/v1alpha1/zz_generated.deepcopy.go index 0bc78db97..7cf62242d 100644 --- a/services/dis-redis-operator/api/v1alpha1/zz_generated.deepcopy.go +++ b/services/dis-redis-operator/api/v1alpha1/zz_generated.deepcopy.go @@ -40,51 +40,6 @@ func (in *ApplicationIdentityRef) DeepCopy() *ApplicationIdentityRef { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ServiceAccountRef) DeepCopyInto(out *ServiceAccountRef) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceAccountRef. -func (in *ServiceAccountRef) DeepCopy() *ServiceAccountRef { - if in == nil { - return nil - } - out := new(ServiceAccountRef) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *RedisModule) DeepCopyInto(out *RedisModule) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RedisModule. -func (in *RedisModule) DeepCopy() *RedisModule { - if in == nil { - return nil - } - out := new(RedisModule) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *RedisPersistence) DeepCopyInto(out *RedisPersistence) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RedisPersistence. -func (in *RedisPersistence) DeepCopy() *RedisPersistence { - if in == nil { - return nil - } - out := new(RedisPersistence) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Redis) DeepCopyInto(out *Redis) { *out = *in @@ -144,6 +99,36 @@ func (in *RedisList) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RedisModule) DeepCopyInto(out *RedisModule) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RedisModule. +func (in *RedisModule) DeepCopy() *RedisModule { + if in == nil { + return nil + } + out := new(RedisModule) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RedisPersistence) DeepCopyInto(out *RedisPersistence) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RedisPersistence. +func (in *RedisPersistence) DeepCopy() *RedisPersistence { + if in == nil { + return nil + } + out := new(RedisPersistence) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *RedisSpec) DeepCopyInto(out *RedisSpec) { *out = *in @@ -212,3 +197,18 @@ func (in *RedisStatus) DeepCopy() *RedisStatus { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceAccountRef) DeepCopyInto(out *ServiceAccountRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceAccountRef. +func (in *ServiceAccountRef) DeepCopy() *ServiceAccountRef { + if in == nil { + return nil + } + out := new(ServiceAccountRef) + in.DeepCopyInto(out) + return out +} diff --git a/services/dis-redis-operator/config/crd/bases/redis.dis.altinn.cloud_redises.yaml b/services/dis-redis-operator/config/crd/bases/redis.dis.altinn.cloud_redises.yaml index a2356068a..8c4b2fb0c 100644 --- a/services/dis-redis-operator/config/crd/bases/redis.dis.altinn.cloud_redises.yaml +++ b/services/dis-redis-operator/config/crd/bases/redis.dis.altinn.cloud_redises.yaml @@ -14,12 +14,7 @@ spec: singular: redis scope: Namespaced versions: - - name: v1alpha1 - served: true - storage: true - subresources: - status: {} - additionalPrinterColumns: + - additionalPrinterColumns: - jsonPath: .status.conditions[?(@.type=='Ready')].status name: Ready type: string @@ -32,65 +27,43 @@ spec: - jsonPath: .status.hostName name: HostName type: string + name: v1alpha1 schema: openAPIV3Schema: description: Redis is the Schema for the redises API. - type: object properties: apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources type: string kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds type: string metadata: type: object spec: - type: object - x-kubernetes-validations: - - rule: "has(self.identityRef) != has(self.serviceAccountRef)" - message: "exactly one of identityRef or serviceAccountRef must be set" + description: Spec defines the desired state of Redis. properties: - identityRef: - type: object - required: - - name - properties: - name: - type: string - minLength: 1 - serviceAccountRef: - type: object - required: - - name - properties: - name: - type: string - minLength: 1 - sku: - type: string - default: Balanced_B0 - enum: - - Balanced_B0 - - Balanced_B1 - - Balanced_B3 - - Balanced_B5 - - Balanced_B10 - - MemoryOptimized_M10 - - MemoryOptimized_M20 - highAvailability: - type: boolean - default: true - version: - type: string - pattern: '^[0-9]+(\.[0-9]+)?$' clientProtocol: - type: string default: Encrypted + description: ClientProtocol selects between Encrypted (TLS) and Plaintext. + Defaults to Encrypted. enum: - Encrypted - Plaintext - evictionPolicy: type: string + evictionPolicy: default: NoEviction + description: EvictionPolicy selects the database eviction policy. + Defaults to NoEviction. enum: - AllKeysLFU - AllKeysLRU @@ -100,90 +73,209 @@ spec: - VolatileRandom - VolatileTTL - NoEviction + type: string + highAvailability: + default: true + description: HighAvailability spreads the cluster across availability + zones. Defaults to true. + type: boolean + identityRef: + description: IdentityRef points to the owning ApplicationIdentity + in the same namespace. + properties: + name: + description: Name is the ApplicationIdentity name in the same + namespace. + minLength: 1 + type: string + required: + - name + type: object modules: - type: array + description: Modules is the optional list of Redis modules enabled + on the database. items: - type: object - required: - - name + description: RedisModule enables a single optional Redis module + on the database. properties: - name: + args: + description: Args are optional, module-specific arguments. type: string + name: + description: Name is the module identifier. enum: - RedisJSON - RediSearch - RedisTimeSeries - RedisBloom - args: type: string + required: + - name + type: object + type: array persistence: - type: object + description: Persistence configures optional AOF / RDB persistence. + Defaults to no persistence. properties: aof: - type: string + description: AOF enables append-only-file persistence with the + specified frequency. enum: - Always - Every1Second - rdb: type: string + rdb: + description: RDB enables snapshot persistence with the specified + frequency. enum: - 1h - 6h - 12h - tags: + type: string + type: object + x-kubernetes-validations: + - message: Only one of 'aof' or 'rdb' may be set + rule: '!(has(self.aof) && has(self.rdb))' + serviceAccountRef: + description: ServiceAccountRef points to the owning ServiceAccount + in the same namespace. + properties: + name: + description: Name is the ServiceAccount name in the same namespace. + minLength: 1 + type: string + required: + - name type: object + sku: + default: Balanced_B0 + description: SKU drives cluster capacity. Defaults to the smallest + Balanced tier. + enum: + - Balanced_B0 + - Balanced_B1 + - Balanced_B3 + - Balanced_B5 + - Balanced_B10 + - MemoryOptimized_M10 + - MemoryOptimized_M20 + type: string + tags: additionalProperties: type: string - status: + description: Tags are optional user-provided tags propagated to Azure + resources. + type: object + version: + description: Version is the Redis version (e.g. "7", "7.4"). Optional; + defaults to the ASO default. + pattern: ^[0-9]+(\.[0-9]+)?$ + type: string type: object + x-kubernetes-validations: + - message: exactly one of identityRef or serviceAccountRef must be set + rule: has(self.identityRef) != has(self.serviceAccountRef) + status: + description: Status defines the observed state of Redis. properties: + accessPolicyAssignmentName: + description: AccessPolicyAssignmentName is the name of the managed + access policy assignment. + type: string + azureName: + description: AzureName is the computed Azure Redis Enterprise cluster + name. + type: string + clusterResourceId: + description: ClusterResourceID is the ARM resource ID of the Redis + Enterprise cluster. + type: string conditions: - type: array + description: Conditions represent the current state of this Redis. items: - type: object - required: - - lastTransitionTime - - message - - reason - - status - - type + description: Condition contains details for one aspect of the current + state of this API Resource. properties: lastTransitionTime: - type: string + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. format: date-time + type: string message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 type: string observedGeneration: - type: integer + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. format: int64 + minimum: 0 + type: integer reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ type: string status: - type: string + description: status of the condition, one of True, False, Unknown. enum: - "True" - "False" - Unknown + type: string type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ type: string - x-kubernetes-list-type: map + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array x-kubernetes-list-map-keys: - type - azureName: - type: string - clusterResourceId: - type: string + x-kubernetes-list-type: map databaseResourceId: + description: DatabaseResourceID is the ARM resource ID of the Redis + Enterprise database. type: string hostName: + description: HostName is the resolved DNS hostname of the cluster + (e.g. "..redis.azure.net"). type: string - port: + observedGeneration: + description: ObservedGeneration is the latest generation reconciled + by the controller. + format: int64 type: integer - format: int32 ownerPrincipalId: + description: OwnerPrincipalID is the resolved owner principal ID. type: string - accessPolicyAssignmentName: - type: string - observedGeneration: + port: + description: Port is the database client port (defaults to 10000 for + Redis Enterprise). + format: int32 type: integer - format: int64 + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/services/dis-redis-operator/config/rbac/role.yaml b/services/dis-redis-operator/config/rbac/role.yaml index 2fdfb8e52..0864928cf 100644 --- a/services/dis-redis-operator/config/rbac/role.yaml +++ b/services/dis-redis-operator/config/rbac/role.yaml @@ -45,9 +45,9 @@ rules: - apiGroups: - network.azure.com resources: - - privateendpoints - privatednszones - privatednszonesvirtualnetworklinks + - privateendpoints verbs: - create - delete @@ -59,9 +59,9 @@ rules: - apiGroups: - network.azure.com resources: - - privateendpoints/status - privatednszones/status - privatednszonesvirtualnetworklinks/status + - privateendpoints/status verbs: - get - patch diff --git a/services/dis-redis-operator/internal/controller/redis_controller.go b/services/dis-redis-operator/internal/controller/redis_controller.go index 0189b9edc..65ec1b9cd 100644 --- a/services/dis-redis-operator/internal/controller/redis_controller.go +++ b/services/dis-redis-operator/internal/controller/redis_controller.go @@ -163,7 +163,9 @@ func (r *RedisReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl logger.Info("reconciled Redis dependencies", "azureName", azureName, "principalId", identity.PrincipalID) if !clusterReady.Found || clusterReady.Status != metav1.ConditionTrue || - !databaseReady.Found || databaseReady.Status != metav1.ConditionTrue { + !databaseReady.Found || databaseReady.Status != metav1.ConditionTrue || + !peReady.Found || peReady.Status != metav1.ConditionTrue || + !dnsReady.Found || dnsReady.Status != metav1.ConditionTrue { return ctrl.Result{RequeueAfter: provisioningRequeueDelay}, nil } @@ -277,7 +279,22 @@ func (r *RedisReconciler) getSharedDNSReady(ctx context.Context, redisObj *redis } return redispkg.ASOReadyCondition{}, err } - return redispkg.FromASOConditions(zone.Status.Conditions), nil + zoneReady := redispkg.FromASOConditions(zone.Status.Conditions) + if !zoneReady.Found || zoneReady.Status != metav1.ConditionTrue { + return zoneReady, nil + } + + link := &networkv1.PrivateDnsZonesVirtualNetworkLink{} + if err := r.Get(ctx, types.NamespacedName{ + Name: redispkg.SharedVNetLinkName(r.Config.Environment), + Namespace: redisObj.Namespace, + }, link); err != nil { + if apierrors.IsNotFound(err) { + return redispkg.ASOReadyCondition{}, nil + } + return redispkg.ASOReadyCondition{}, err + } + return redispkg.FromASOConditions(link.Status.Conditions), nil } func setStatusCondition(redisObj *redisv1alpha1.Redis, condition metav1.Condition) bool { diff --git a/services/dis-redis-operator/internal/controller/redis_controller_status.go b/services/dis-redis-operator/internal/controller/redis_controller_status.go index 8879f2b2b..618ee6146 100644 --- a/services/dis-redis-operator/internal/controller/redis_controller_status.go +++ b/services/dis-redis-operator/internal/controller/redis_controller_status.go @@ -33,30 +33,34 @@ func (r *RedisReconciler) updateStatus( } identityCond := applyCondition(buildIdentityCondition(redisObj, identity)) - clusterCond := applyCondition(asoToStatusCondition( + clusterCond := applyCondition(buildDependentCondition( redisObj.Generation, redisv1alpha1.ConditionClusterReady, + identity, clusterReady, "ClusterNotReady", "waiting for ASO RedisEnterprise readiness", )) - databaseCond := applyCondition(asoToStatusCondition( + databaseCond := applyCondition(buildDependentCondition( redisObj.Generation, redisv1alpha1.ConditionDatabaseReady, + identity, databaseReady, "DatabaseNotReady", "waiting for ASO RedisEnterpriseDatabase readiness", )) - peCond := applyCondition(asoToStatusCondition( + peCond := applyCondition(buildDependentCondition( redisObj.Generation, redisv1alpha1.ConditionPrivateEndpointReady, + identity, privateEndpointReady, "PrivateEndpointNotReady", "waiting for ASO PrivateEndpoint readiness", )) - dnsCond := applyCondition(asoToStatusCondition( + dnsCond := applyCondition(buildDependentCondition( redisObj.Generation, redisv1alpha1.ConditionPrivateDNSReady, + identity, privateDNSReady, "PrivateDNSNotReady", "waiting for ASO shared private DNS zone readiness", @@ -118,6 +122,25 @@ func buildIdentityCondition(redisObj *redisv1alpha1.Redis, identity redispkg.Res ) } +func buildDependentCondition( + generation int64, + conditionType redisv1alpha1.ConditionType, + identity redispkg.ResolvedIdentity, + input redispkg.ASOReadyCondition, + notReadyReason, notReadyMessage string, +) metav1.Condition { + if identity.IsPending() { + return redispkg.NewCondition( + conditionType, + generation, + metav1.ConditionFalse, + identity.PendingReason, + fmt.Sprintf("waiting for owner identity before reconciling dependency: %s", identity.PendingMessage), + ) + } + return asoToStatusCondition(generation, conditionType, input, notReadyReason, notReadyMessage) +} + func asoToStatusCondition( generation int64, conditionType redisv1alpha1.ConditionType, diff --git a/services/dis-redis-operator/internal/redis/builders.go b/services/dis-redis-operator/internal/redis/builders.go index 941f0d50c..b12748219 100644 --- a/services/dis-redis-operator/internal/redis/builders.go +++ b/services/dis-redis-operator/internal/redis/builders.go @@ -176,6 +176,9 @@ func BuildPrivateEndpoint(r *redisv1alpha1.Redis, cfg config.OperatorConfig, clu if r == nil { return nil, fmt.Errorf("redis must not be nil") } + if strings.TrimSpace(clusterKubernetesName) == "" { + return nil, fmt.Errorf("clusterKubernetesName must not be empty") + } subnetID := cfg.PrimarySubnetID() if subnetID == "" { return nil, fmt.Errorf("no AKS subnet configured for private endpoint") diff --git a/services/dis-redis-operator/internal/redis/identity.go b/services/dis-redis-operator/internal/redis/identity.go index c8de9ad25..8a7347318 100644 --- a/services/dis-redis-operator/internal/redis/identity.go +++ b/services/dis-redis-operator/internal/redis/identity.go @@ -59,6 +59,10 @@ func ActiveAuthReferenceName(r *redisv1alpha1.Redis) (string, error) { return "", fmt.Errorf("redis must not be nil") } + if r.Spec.IdentityRef != nil && r.Spec.ServiceAccountRef != nil { + return "", fmt.Errorf("exactly one of identityRef or serviceAccountRef must be set") + } + switch { case r.Spec.ServiceAccountRef != nil: name := strings.TrimSpace(r.Spec.ServiceAccountRef.Name) diff --git a/services/dis-redis-operator/internal/redis/status.go b/services/dis-redis-operator/internal/redis/status.go index 0f7723112..a7030a4c5 100644 --- a/services/dis-redis-operator/internal/redis/status.go +++ b/services/dis-redis-operator/internal/redis/status.go @@ -49,6 +49,16 @@ func NewCondition( // AggregateReadyCondition combines child conditions into a single Ready condition. func AggregateReadyCondition(generation int64, conditions ...metav1.Condition) metav1.Condition { + if len(conditions) == 0 { + return NewCondition( + redisv1alpha1.ConditionReady, + generation, + metav1.ConditionUnknown, + "NoDependencies", + "no dependency conditions present", + ) + } + hasFalse := false hasUnknown := false for _, cond := range conditions { From 1e78749f85bbab17f2905a2d6d76f42897edecbb Mon Sep 17 00:00:00 2001 From: Are Almaas Date: Wed, 20 May 2026 08:40:14 -0300 Subject: [PATCH 03/11] refactor(dis-cache-operator): rename dis-redis-operator to dis-cache-operator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mechanical rename of the operator prefix from dis-redis to dis-cache: - services/dis-redis-operator/ moved to services/dis-cache-operator/ - workflow files dis-redis-*.yml renamed to dis-cache-*.yml - Go module path, image name, release tag prefix, Flux artifact name, leader-election ID, and all kustomize labels/selectors updated - CRD Kind (Redis), API group (redis.dis.altinn.cloud), and internal/redis package are unchanged — only the operator name carries the new prefix Co-Authored-By: Claude Opus 4.7 (1M context) --- ...-lint-test.yml => dis-cache-lint-test.yml} | 22 +++++++------- ...edis-release.yml => dis-cache-release.yml} | 30 +++++++++---------- .release-please-manifest.json | 2 +- pr_description.md | 6 ++-- release-please-config.json | 4 +-- rfcs/0014-self-service-managed-redis.md | 6 ++-- .../0f974381-8734-4756-95d3-bc013825cdcd.json | 0 .../AGENTS.md | 2 +- .../CHANGELOG.md | 0 .../Dockerfile | 0 .../Makefile | 16 +++++----- .../PROJECT | 4 +-- .../README.md | 2 +- .../api/v1alpha1/groupversion_info.go | 0 .../api/v1alpha1/redis_types.go | 0 .../api/v1alpha1/zz_generated.deepcopy.go | 0 .../cmd/main.go | 6 ++-- .../bases/redis.dis.altinn.cloud_redises.yaml | 0 .../config/crd/kustomization.yaml | 0 .../config/default/deploy_vars_patch.yaml | 0 .../config/default/kustomization.yaml | 4 +-- .../config/default/manager_metrics_patch.yaml | 0 .../config/default/metrics_service.yaml | 4 +-- .../config/kind/applicationidentities.yaml | 0 .../config/kind/kustomization.yaml | 4 +-- .../config/kind/manager_kind_patch.yaml | 4 +-- .../config/kind/serviceaccounts.yaml | 0 .../config/manager/kustomization.yaml | 0 .../config/manager/manager.yaml | 8 ++--- .../network-policy/allow-metrics-traffic.yaml | 4 +-- .../config/network-policy/kustomization.yaml | 0 .../config/prometheus/kustomization.yaml | 0 .../config/prometheus/monitor.yaml | 4 +-- .../config/rbac/kustomization.yaml | 0 .../config/rbac/leader_election_role.yaml | 2 +- .../rbac/leader_election_role_binding.yaml | 2 +- .../config/rbac/metrics_auth_role.yaml | 0 .../rbac/metrics_auth_role_binding.yaml | 0 .../config/rbac/metrics_reader_role.yaml | 0 .../config/rbac/role.yaml | 0 .../config/rbac/role_binding.yaml | 2 +- .../config/rbac/service_account.yaml | 2 +- .../config/samples/kustomization.yaml | 0 .../config/samples/redis_v1alpha1_redis.yaml | 2 +- .../redis_v1alpha1_service_account_redis.yaml | 2 +- .../go.mod | 2 +- .../go.sum | 0 .../hack/boilerplate.go.txt | 0 .../internal/config/config.go | 0 .../internal/config/config_test.go | 0 .../internal/controller/redis_auth_watch.go | 2 +- .../internal/controller/redis_controller.go | 6 ++-- .../controller/redis_controller_network.go | 6 ++-- .../controller/redis_controller_role.go | 0 .../controller/redis_controller_status.go | 4 +-- .../controller/redis_controller_test.go | 0 .../internal/controller/suite_test.go | 0 .../internal/redis/builders.go | 4 +-- .../internal/redis/builders_test.go | 4 +-- .../internal/redis/identity.go | 2 +- .../internal/redis/identity_test.go | 2 +- .../internal/redis/labels.go | 2 +- .../internal/redis/naming.go | 0 .../internal/redis/naming_test.go | 0 .../internal/redis/status.go | 2 +- .../version.txt | 0 66 files changed, 90 insertions(+), 90 deletions(-) rename .github/workflows/{dis-redis-lint-test.yml => dis-cache-lint-test.yml} (64%) rename .github/workflows/{dis-redis-release.yml => dis-cache-release.yml} (76%) rename services/{dis-redis-operator => dis-cache-operator}/.omc/sessions/0f974381-8734-4756-95d3-bc013825cdcd.json (100%) rename services/{dis-redis-operator => dis-cache-operator}/AGENTS.md (98%) rename services/{dis-redis-operator => dis-cache-operator}/CHANGELOG.md (100%) rename services/{dis-redis-operator => dis-cache-operator}/Dockerfile (100%) rename services/{dis-redis-operator => dis-cache-operator}/Makefile (96%) rename services/{dis-redis-operator => dis-cache-operator}/PROJECT (76%) rename services/{dis-redis-operator => dis-cache-operator}/README.md (97%) rename services/{dis-redis-operator => dis-cache-operator}/api/v1alpha1/groupversion_info.go (100%) rename services/{dis-redis-operator => dis-cache-operator}/api/v1alpha1/redis_types.go (100%) rename services/{dis-redis-operator => dis-cache-operator}/api/v1alpha1/zz_generated.deepcopy.go (100%) rename services/{dis-redis-operator => dis-cache-operator}/cmd/main.go (97%) rename services/{dis-redis-operator => dis-cache-operator}/config/crd/bases/redis.dis.altinn.cloud_redises.yaml (100%) rename services/{dis-redis-operator => dis-cache-operator}/config/crd/kustomization.yaml (100%) rename services/{dis-redis-operator => dis-cache-operator}/config/default/deploy_vars_patch.yaml (100%) rename services/{dis-redis-operator => dis-cache-operator}/config/default/kustomization.yaml (74%) rename services/{dis-redis-operator => dis-cache-operator}/config/default/manager_metrics_patch.yaml (100%) rename services/{dis-redis-operator => dis-cache-operator}/config/default/metrics_service.yaml (77%) rename services/{dis-redis-operator => dis-cache-operator}/config/kind/applicationidentities.yaml (100%) rename services/{dis-redis-operator => dis-cache-operator}/config/kind/kustomization.yaml (70%) rename services/{dis-redis-operator => dis-cache-operator}/config/kind/manager_kind_patch.yaml (93%) rename services/{dis-redis-operator => dis-cache-operator}/config/kind/serviceaccounts.yaml (100%) rename services/{dis-redis-operator => dis-cache-operator}/config/manager/kustomization.yaml (100%) rename services/{dis-redis-operator => dis-cache-operator}/config/manager/manager.yaml (89%) rename services/{dis-redis-operator => dis-cache-operator}/config/network-policy/allow-metrics-traffic.yaml (81%) rename services/{dis-redis-operator => dis-cache-operator}/config/network-policy/kustomization.yaml (100%) rename services/{dis-redis-operator => dis-cache-operator}/config/prometheus/kustomization.yaml (100%) rename services/{dis-redis-operator => dis-cache-operator}/config/prometheus/monitor.yaml (83%) rename services/{dis-redis-operator => dis-cache-operator}/config/rbac/kustomization.yaml (100%) rename services/{dis-redis-operator => dis-cache-operator}/config/rbac/leader_election_role.yaml (91%) rename services/{dis-redis-operator => dis-cache-operator}/config/rbac/leader_election_role_binding.yaml (87%) rename services/{dis-redis-operator => dis-cache-operator}/config/rbac/metrics_auth_role.yaml (100%) rename services/{dis-redis-operator => dis-cache-operator}/config/rbac/metrics_auth_role_binding.yaml (100%) rename services/{dis-redis-operator => dis-cache-operator}/config/rbac/metrics_reader_role.yaml (100%) rename services/{dis-redis-operator => dis-cache-operator}/config/rbac/role.yaml (100%) rename services/{dis-redis-operator => dis-cache-operator}/config/rbac/role_binding.yaml (87%) rename services/{dis-redis-operator => dis-cache-operator}/config/rbac/service_account.yaml (75%) rename services/{dis-redis-operator => dis-cache-operator}/config/samples/kustomization.yaml (100%) rename services/{dis-redis-operator => dis-cache-operator}/config/samples/redis_v1alpha1_redis.yaml (87%) rename services/{dis-redis-operator => dis-cache-operator}/config/samples/redis_v1alpha1_service_account_redis.yaml (88%) rename services/{dis-redis-operator => dis-cache-operator}/go.mod (98%) rename services/{dis-redis-operator => dis-cache-operator}/go.sum (100%) rename services/{dis-redis-operator => dis-cache-operator}/hack/boilerplate.go.txt (100%) rename services/{dis-redis-operator => dis-cache-operator}/internal/config/config.go (100%) rename services/{dis-redis-operator => dis-cache-operator}/internal/config/config_test.go (100%) rename services/{dis-redis-operator => dis-cache-operator}/internal/controller/redis_auth_watch.go (94%) rename services/{dis-redis-operator => dis-cache-operator}/internal/controller/redis_controller.go (98%) rename services/{dis-redis-operator => dis-cache-operator}/internal/controller/redis_controller_network.go (92%) rename services/{dis-redis-operator => dis-cache-operator}/internal/controller/redis_controller_role.go (100%) rename services/{dis-redis-operator => dis-cache-operator}/internal/controller/redis_controller_status.go (97%) rename services/{dis-redis-operator => dis-cache-operator}/internal/controller/redis_controller_test.go (100%) rename services/{dis-redis-operator => dis-cache-operator}/internal/controller/suite_test.go (100%) rename services/{dis-redis-operator => dis-cache-operator}/internal/redis/builders.go (98%) rename services/{dis-redis-operator => dis-cache-operator}/internal/redis/builders_test.go (96%) rename services/{dis-redis-operator => dis-cache-operator}/internal/redis/identity.go (98%) rename services/{dis-redis-operator => dis-cache-operator}/internal/redis/identity_test.go (98%) rename services/{dis-redis-operator => dis-cache-operator}/internal/redis/labels.go (90%) rename services/{dis-redis-operator => dis-cache-operator}/internal/redis/naming.go (100%) rename services/{dis-redis-operator => dis-cache-operator}/internal/redis/naming_test.go (100%) rename services/{dis-redis-operator => dis-cache-operator}/internal/redis/status.go (96%) rename services/{dis-redis-operator => dis-cache-operator}/version.txt (100%) diff --git a/.github/workflows/dis-redis-lint-test.yml b/.github/workflows/dis-cache-lint-test.yml similarity index 64% rename from .github/workflows/dis-redis-lint-test.yml rename to .github/workflows/dis-cache-lint-test.yml index c74123814..0ddcdac53 100644 --- a/.github/workflows/dis-redis-lint-test.yml +++ b/.github/workflows/dis-cache-lint-test.yml @@ -5,14 +5,14 @@ on: branches: - main paths: - - services/dis-redis-operator/** - - .github/workflows/dis-redis-lint-test.yml + - services/dis-cache-operator/** + - .github/workflows/dis-cache-lint-test.yml pull_request: branches: - main paths: - - services/dis-redis-operator/** - - .github/workflows/dis-redis-lint-test.yml + - services/dis-cache-operator/** + - .github/workflows/dis-cache-lint-test.yml permissions: contents: read @@ -23,7 +23,7 @@ jobs: runs-on: ubuntu-latest defaults: run: - working-directory: services/dis-redis-operator + working-directory: services/dis-cache-operator steps: - name: Clone the code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -31,20 +31,20 @@ jobs: - name: Setup Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version-file: "services/dis-redis-operator/go.mod" - cache-dependency-path: "services/dis-redis-operator/go.sum" + go-version-file: "services/dis-cache-operator/go.mod" + cache-dependency-path: "services/dis-cache-operator/go.sum" - name: Run linter uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0 with: version: v2.11.4 - working-directory: services/dis-redis-operator + working-directory: services/dis-cache-operator test: name: Run tests on Ubuntu runs-on: ubuntu-latest defaults: run: - working-directory: services/dis-redis-operator + working-directory: services/dis-cache-operator steps: - name: Clone the code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -52,8 +52,8 @@ jobs: - name: Setup Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version-file: "services/dis-redis-operator/go.mod" - cache-dependency-path: "services/dis-redis-operator/go.sum" + go-version-file: "services/dis-cache-operator/go.mod" + cache-dependency-path: "services/dis-cache-operator/go.sum" - name: Running Tests run: | diff --git a/.github/workflows/dis-redis-release.yml b/.github/workflows/dis-cache-release.yml similarity index 76% rename from .github/workflows/dis-redis-release.yml rename to .github/workflows/dis-cache-release.yml index 9904fc114..07f1ec85d 100644 --- a/.github/workflows/dis-redis-release.yml +++ b/.github/workflows/dis-cache-release.yml @@ -1,25 +1,25 @@ name: Scan/Release Dis Redis Operator Image and Build Kustomize OCI Artifact env: - FLUX_ARTIFACT_NAME: dis/kustomize/dis-redis-operator + FLUX_ARTIFACT_NAME: dis/kustomize/dis-cache-operator on: pull_request: branches: - main paths: - - .github/workflows/dis-redis-release.yml - - services/dis-redis-operator/** + - .github/workflows/dis-cache-release.yml + - services/dis-cache-operator/** - .github/workflows/reusable-image-scan-and-release-ghcr.yml push: branches: - main paths: - - .github/workflows/dis-redis-release.yml - - services/dis-redis-operator/** + - .github/workflows/dis-cache-release.yml + - services/dis-cache-operator/** - .github/workflows/reusable-image-scan-and-release-ghcr.yml tags: - - "dis-redis-v*" + - "dis-cache-v*" workflow_dispatch: {} permissions: @@ -34,10 +34,10 @@ jobs: uses: ./.github/workflows/reusable-image-scan-and-release-ghcr.yml with: release_latest: true - image_name: dis-redis-operator - tag_prefix: dis-redis-v + image_name: dis-cache-operator + tag_prefix: dis-cache-v platforms: "linux/amd64" - workdir: ./services/dis-redis-operator + workdir: ./services/dis-cache-operator build-release-flux-oci-latest: name: Build latest from main if: github.ref == 'refs/heads/main' @@ -47,7 +47,7 @@ jobs: id-token: write defaults: run: - working-directory: ./services/dis-redis-operator/config + working-directory: ./services/dis-cache-operator/config runs-on: ubuntu-latest steps: - name: Checkout @@ -57,7 +57,7 @@ jobs: - name: Push latest flux oci image from main uses: ./actions/flux/build-push-image with: - workdir: ./services/dis-redis-operator/config + workdir: ./services/dis-cache-operator/config image_name: ${{ env.FLUX_ARTIFACT_NAME }} tag: latest azure_subscription_id: ${{ secrets.AZURE_ALTINNACR_SUBSCRIPTION_ID }} @@ -65,7 +65,7 @@ jobs: azure_tenant_id: ${{ secrets.AZURE_TENANT_ID }} build-release-flux-oci-release: name: Build release from tag - if: startsWith(github.ref, 'refs/tags/dis-redis-v') + if: startsWith(github.ref, 'refs/tags/dis-cache-v') environment: flux-release permissions: contents: read @@ -73,7 +73,7 @@ jobs: id-token: write defaults: run: - working-directory: ./services/dis-redis-operator/config + working-directory: ./services/dis-cache-operator/config runs-on: ubuntu-latest steps: - name: Checkout @@ -83,12 +83,12 @@ jobs: - name: Setup vars id: vars run: | - tag="${GITHUB_REF/refs\/tags\/dis-redis-/}" + tag="${GITHUB_REF/refs\/tags\/dis-cache-/}" echo "tag=${tag}" >> ${GITHUB_OUTPUT} - name: Push flux oci image from release tag uses: ./actions/flux/build-push-image with: - workdir: ./services/dis-redis-operator/config + workdir: ./services/dis-cache-operator/config image_name: ${{ env.FLUX_ARTIFACT_NAME }} tag: ${{ steps.vars.outputs.tag }} azure_subscription_id: ${{ secrets.AZURE_ALTINNACR_SUBSCRIPTION_ID }} diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 2b0fed659..d996773b7 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -3,7 +3,7 @@ "services/dis-identity-operator": "0.2.1", "services/dis-apim-operator": "1.0.0", "services/dis-vault-operator": "1.4.3", - "services/dis-redis-operator": "0.1.0", + "services/dis-cache-operator": "0.1.0", "services/lakmus": "1.1.0", "infrastructure/images/azure-devops-agent": "1.2.7", "infrastructure/images/gh-runner": "0.6.2", diff --git a/pr_description.md b/pr_description.md index d7afc2577..ea0e7fa75 100644 --- a/pr_description.md +++ b/pr_description.md @@ -1,6 +1,6 @@ -# Add dis-redis-operator (RFC 0014) — self-service Azure Managed Redis +# Add dis-cache-operator (RFC 0014) — self-service Azure Managed Redis -This PR scaffolds a new operator, `dis-redis-operator`, that reconciles a `Redis` CR into Azure Managed Redis (`Microsoft.Cache/redisEnterprise`) via Azure Service Operator. It mirrors the proven patterns from `dis-vault-operator` (single-resource-per-CR, federated-identity-owned) and `dis-pgsql-operator` (private endpoint + shared private DNS + AKS VNet link). +This PR scaffolds a new operator, `dis-cache-operator`, that reconciles a `Redis` CR into Azure Managed Redis (`Microsoft.Cache/redisEnterprise`) via Azure Service Operator. It mirrors the proven patterns from `dis-vault-operator` (single-resource-per-CR, federated-identity-owned) and `dis-pgsql-operator` (private endpoint + shared private DNS + AKS VNet link). See [RFC 0014](rfcs/0014-self-service-managed-redis.md) for the full design. @@ -44,7 +44,7 @@ See [RFC 0014](rfcs/0014-self-service-managed-redis.md) for the full design. │ │ ▼ │ ┌────────────────────────────┐ │ - │ dis-redis-operator │ │ + │ dis-cache-operator │ │ └─────┬───────────┬──────────┘ │ │ │ │ owns: ┌────▼───┐ ┌────▼──────┐ │ diff --git a/release-please-config.json b/release-please-config.json index 1dedca2e4..efb79de65 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -16,9 +16,9 @@ "release-type": "simple", "component": "dis-vault" }, - "services/dis-redis-operator": { + "services/dis-cache-operator": { "release-type": "simple", - "component": "dis-redis" + "component": "dis-cache" }, "services/lakmus": { "release-type": "simple", diff --git a/rfcs/0014-self-service-managed-redis.md b/rfcs/0014-self-service-managed-redis.md index aac2ae3aa..f7299b332 100644 --- a/rfcs/0014-self-service-managed-redis.md +++ b/rfcs/0014-self-service-managed-redis.md @@ -7,7 +7,7 @@ # Summary -This RFC proposes a new Kubernetes operator, `dis-redis-operator`, that provides self-service Azure Managed Redis (`Microsoft.Cache/redisEnterprise`) provisioning for DIS applications. App teams declare a `Redis` custom resource in Kubernetes, and the operator reconciles it into Azure resources through Azure Service Operator (ASO). The proposal is opinionated: one Redis Enterprise cluster + one database per CR, Entra-only data-plane authentication via federated identity, and private-endpoint-only network access with a shared private DNS zone linked to the AKS VNet. No shared access keys are exposed to workloads. +This RFC proposes a new Kubernetes operator, `dis-cache-operator`, that provides self-service Azure Managed Redis (`Microsoft.Cache/redisEnterprise`) provisioning for DIS applications. App teams declare a `Redis` custom resource in Kubernetes, and the operator reconciles it into Azure resources through Azure Service Operator (ASO). The proposal is opinionated: one Redis Enterprise cluster + one database per CR, Entra-only data-plane authentication via federated identity, and private-endpoint-only network access with a shared private DNS zone linked to the AKS VNet. No shared access keys are exposed to workloads. # Motivation @@ -138,7 +138,7 @@ Application code connects to the cache over TLS on port 10000, using an Entra-aw `dis-pgsql-operator` uses a per-instance private DNS zone (`{db-name}.private.postgres.database.azure.com`), which works because PG Flexible Server supports BYO-DNS. Azure Managed Redis does not: the private endpoint CNAME chain hard-codes `..privatelink.redis.azure.net` as the target, so the zone literally has to be named `privatelink.redis.azure.net` for resolution to work. Splitting it per-CR would break DNS resolution. -The operator therefore uses an idempotent get-or-create for the zone + VNet link, identified by a `redis.dis.altinn.cloud/managed-by=dis-redis-operator` label rather than an owner-ref. The per-instance `privateEndpoint` and `privateDnsZoneGroup` keep their owner-refs to the `Redis` CR, so deletion cascades correctly for those. +The operator therefore uses an idempotent get-or-create for the zone + VNet link, identified by a `redis.dis.altinn.cloud/managed-by=dis-cache-operator` label rather than an owner-ref. The per-instance `privateEndpoint` and `privateDnsZoneGroup` keep their owner-refs to the `Redis` CR, so deletion cascades correctly for those. ## Reconciliation flow @@ -146,7 +146,7 @@ The operator therefore uses an idempotent get-or-create for the zone + VNet link sequenceDiagram participant dev as App Team participant kapi as Kubernetes API -participant redisop as dis-redis-operator +participant redisop as dis-cache-operator participant aso as Azure Service Operator participant azure as Azure Redis/Network/RBAC diff --git a/services/dis-redis-operator/.omc/sessions/0f974381-8734-4756-95d3-bc013825cdcd.json b/services/dis-cache-operator/.omc/sessions/0f974381-8734-4756-95d3-bc013825cdcd.json similarity index 100% rename from services/dis-redis-operator/.omc/sessions/0f974381-8734-4756-95d3-bc013825cdcd.json rename to services/dis-cache-operator/.omc/sessions/0f974381-8734-4756-95d3-bc013825cdcd.json diff --git a/services/dis-redis-operator/AGENTS.md b/services/dis-cache-operator/AGENTS.md similarity index 98% rename from services/dis-redis-operator/AGENTS.md rename to services/dis-cache-operator/AGENTS.md index eab249a0a..5446c42f6 100644 --- a/services/dis-redis-operator/AGENTS.md +++ b/services/dis-cache-operator/AGENTS.md @@ -15,7 +15,7 @@ - List targets: `make help` ## Local workspace (dev only) -- There is no predefined module pairing yet for `dis-redis-operator`. +- There is no predefined module pairing yet for `dis-cache-operator`. - Add workspace pairings only after the module dependency pattern is established. - If you create a local `go.work`/`go.work.sum`, keep them uncommitted (dev-only artifacts). - Avoid running `go work sync` on shared branches; it can update other modules' `go.mod`/`go.sum`. diff --git a/services/dis-redis-operator/CHANGELOG.md b/services/dis-cache-operator/CHANGELOG.md similarity index 100% rename from services/dis-redis-operator/CHANGELOG.md rename to services/dis-cache-operator/CHANGELOG.md diff --git a/services/dis-redis-operator/Dockerfile b/services/dis-cache-operator/Dockerfile similarity index 100% rename from services/dis-redis-operator/Dockerfile rename to services/dis-cache-operator/Dockerfile diff --git a/services/dis-redis-operator/Makefile b/services/dis-cache-operator/Makefile similarity index 96% rename from services/dis-redis-operator/Makefile rename to services/dis-cache-operator/Makefile index 82b67d977..f8b008e62 100644 --- a/services/dis-redis-operator/Makefile +++ b/services/dis-cache-operator/Makefile @@ -2,7 +2,7 @@ # Keep the localhost/ prefix: in local Kind flows we load this image directly, # and this avoids accidental registry lookups when the image name is overridden. IMG ?= localhost/controller:latest -KIND_IMAGE_ARCHIVE := bin/dis-redis-operator_image.tar +KIND_IMAGE_ARCHIVE := bin/dis-cache-operator_image.tar # Local Go cache settings for CI check runs CACHE_DIR ?= $(CURDIR)/.cache @@ -38,13 +38,13 @@ help: ## Display this help. @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) .PHONY: workspace -workspace: ## Create local go.work for dev across dis-redis-operator and dis-identity-operator (not committed). +workspace: ## Create local go.work for dev across dis-cache-operator and dis-identity-operator (not committed). @cd "$(WORKSPACE_ROOT)" && \ if [ -f go.work ]; then \ echo "go.work already exists; updating modules"; \ - go work use ./services/dis-redis-operator ./services/dis-identity-operator; \ + go work use ./services/dis-cache-operator ./services/dis-identity-operator; \ else \ - go work init ./services/dis-redis-operator ./services/dis-identity-operator; \ + go work init ./services/dis-cache-operator ./services/dis-identity-operator; \ fi @echo "Local workspace at $(WORKSPACE_ROOT)/go.work (ignored by git)." @@ -126,7 +126,7 @@ run-checks-ci: ## Run CI checks with local Go caches. $(MAKE) test-ci $(MAKE) lint -KIND_CLUSTER ?= dis-redis-operator-test-e2e +KIND_CLUSTER ?= dis-cache-operator-test-e2e KIND_KUBECONFIG ?= $(LOCALBIN)/kubeconfig-$(KIND_CLUSTER) .PHONY: setup-test-e2e @@ -196,10 +196,10 @@ PLATFORMS ?= linux/arm64,linux/amd64,linux/s390x,linux/ppc64le .PHONY: docker-buildx docker-buildx: ## Build and push docker image for the manager for cross-platform support sed -e '1 s/\(^FROM\)/FROM --platform=\$$\{BUILDPLATFORM\}/; t' -e ' 1,// s//FROM --platform=\$$\{BUILDPLATFORM\}/' Dockerfile > Dockerfile.cross - - $(CONTAINER_TOOL) buildx create --name dis-redis-operator-builder - $(CONTAINER_TOOL) buildx use dis-redis-operator-builder + - $(CONTAINER_TOOL) buildx create --name dis-cache-operator-builder + $(CONTAINER_TOOL) buildx use dis-cache-operator-builder - $(CONTAINER_TOOL) buildx build --push --platform=$(PLATFORMS) --tag ${IMG} -f Dockerfile.cross . - - $(CONTAINER_TOOL) buildx rm dis-redis-operator-builder + - $(CONTAINER_TOOL) buildx rm dis-cache-operator-builder rm Dockerfile.cross .PHONY: build-installer diff --git a/services/dis-redis-operator/PROJECT b/services/dis-cache-operator/PROJECT similarity index 76% rename from services/dis-redis-operator/PROJECT rename to services/dis-cache-operator/PROJECT index d3c4a76c6..7cd198478 100644 --- a/services/dis-redis-operator/PROJECT +++ b/services/dis-cache-operator/PROJECT @@ -6,6 +6,6 @@ cliVersion: 4.10.1 domain: dis.altinn.cloud layout: - go.kubebuilder.io/v4 -projectName: dis-redis-operator -repo: github.com/Altinn/altinn-platform/services/dis-redis-operator +projectName: dis-cache-operator +repo: github.com/Altinn/altinn-platform/services/dis-cache-operator version: "3" diff --git a/services/dis-redis-operator/README.md b/services/dis-cache-operator/README.md similarity index 97% rename from services/dis-redis-operator/README.md rename to services/dis-cache-operator/README.md index c5fdbe488..92f3521f0 100644 --- a/services/dis-redis-operator/README.md +++ b/services/dis-cache-operator/README.md @@ -1,4 +1,4 @@ -# dis-redis-operator +# dis-cache-operator Self-service provisioning of Azure Managed Redis (`Microsoft.Cache/redisEnterprise`) for DIS application teams. App teams declare a `Redis` custom resource in their namespace, and the operator reconciles it into an Azure Redis Enterprise cluster + database with a private endpoint and Entra-only data-plane access. diff --git a/services/dis-redis-operator/api/v1alpha1/groupversion_info.go b/services/dis-cache-operator/api/v1alpha1/groupversion_info.go similarity index 100% rename from services/dis-redis-operator/api/v1alpha1/groupversion_info.go rename to services/dis-cache-operator/api/v1alpha1/groupversion_info.go diff --git a/services/dis-redis-operator/api/v1alpha1/redis_types.go b/services/dis-cache-operator/api/v1alpha1/redis_types.go similarity index 100% rename from services/dis-redis-operator/api/v1alpha1/redis_types.go rename to services/dis-cache-operator/api/v1alpha1/redis_types.go diff --git a/services/dis-redis-operator/api/v1alpha1/zz_generated.deepcopy.go b/services/dis-cache-operator/api/v1alpha1/zz_generated.deepcopy.go similarity index 100% rename from services/dis-redis-operator/api/v1alpha1/zz_generated.deepcopy.go rename to services/dis-cache-operator/api/v1alpha1/zz_generated.deepcopy.go diff --git a/services/dis-redis-operator/cmd/main.go b/services/dis-cache-operator/cmd/main.go similarity index 97% rename from services/dis-redis-operator/cmd/main.go rename to services/dis-cache-operator/cmd/main.go index 5e5e5f197..3835e06a6 100644 --- a/services/dis-redis-operator/cmd/main.go +++ b/services/dis-cache-operator/cmd/main.go @@ -21,10 +21,10 @@ import ( "flag" "os" + redisv1alpha1 "github.com/Altinn/altinn-platform/services/dis-cache-operator/api/v1alpha1" + "github.com/Altinn/altinn-platform/services/dis-cache-operator/internal/config" + "github.com/Altinn/altinn-platform/services/dis-cache-operator/internal/controller" identityv1alpha1 "github.com/Altinn/altinn-platform/services/dis-identity-operator/api/v1alpha1" - redisv1alpha1 "github.com/Altinn/altinn-platform/services/dis-redis-operator/api/v1alpha1" - "github.com/Altinn/altinn-platform/services/dis-redis-operator/internal/config" - "github.com/Altinn/altinn-platform/services/dis-redis-operator/internal/controller" cachev1 "github.com/Azure/azure-service-operator/v2/api/cache/v1api20250401" pev1 "github.com/Azure/azure-service-operator/v2/api/network/v1api20220701" networkv1 "github.com/Azure/azure-service-operator/v2/api/network/v1api20240601" diff --git a/services/dis-redis-operator/config/crd/bases/redis.dis.altinn.cloud_redises.yaml b/services/dis-cache-operator/config/crd/bases/redis.dis.altinn.cloud_redises.yaml similarity index 100% rename from services/dis-redis-operator/config/crd/bases/redis.dis.altinn.cloud_redises.yaml rename to services/dis-cache-operator/config/crd/bases/redis.dis.altinn.cloud_redises.yaml diff --git a/services/dis-redis-operator/config/crd/kustomization.yaml b/services/dis-cache-operator/config/crd/kustomization.yaml similarity index 100% rename from services/dis-redis-operator/config/crd/kustomization.yaml rename to services/dis-cache-operator/config/crd/kustomization.yaml diff --git a/services/dis-redis-operator/config/default/deploy_vars_patch.yaml b/services/dis-cache-operator/config/default/deploy_vars_patch.yaml similarity index 100% rename from services/dis-redis-operator/config/default/deploy_vars_patch.yaml rename to services/dis-cache-operator/config/default/deploy_vars_patch.yaml diff --git a/services/dis-redis-operator/config/default/kustomization.yaml b/services/dis-cache-operator/config/default/kustomization.yaml similarity index 74% rename from services/dis-redis-operator/config/default/kustomization.yaml rename to services/dis-cache-operator/config/default/kustomization.yaml index 68299874c..6e8dd8343 100644 --- a/services/dis-redis-operator/config/default/kustomization.yaml +++ b/services/dis-cache-operator/config/default/kustomization.yaml @@ -1,5 +1,5 @@ -namespace: dis-redis-operator-system -namePrefix: dis-redis-operator- +namespace: dis-cache-operator-system +namePrefix: dis-cache-operator- resources: - ../crd diff --git a/services/dis-redis-operator/config/default/manager_metrics_patch.yaml b/services/dis-cache-operator/config/default/manager_metrics_patch.yaml similarity index 100% rename from services/dis-redis-operator/config/default/manager_metrics_patch.yaml rename to services/dis-cache-operator/config/default/manager_metrics_patch.yaml diff --git a/services/dis-redis-operator/config/default/metrics_service.yaml b/services/dis-cache-operator/config/default/metrics_service.yaml similarity index 77% rename from services/dis-redis-operator/config/default/metrics_service.yaml rename to services/dis-cache-operator/config/default/metrics_service.yaml index ccfc05ee3..93d4aa099 100644 --- a/services/dis-redis-operator/config/default/metrics_service.yaml +++ b/services/dis-cache-operator/config/default/metrics_service.yaml @@ -3,7 +3,7 @@ kind: Service metadata: labels: control-plane: controller-manager - app.kubernetes.io/name: dis-redis-operator + app.kubernetes.io/name: dis-cache-operator app.kubernetes.io/managed-by: kustomize name: controller-manager-metrics-service namespace: system @@ -15,4 +15,4 @@ spec: targetPort: 8443 selector: control-plane: controller-manager - app.kubernetes.io/name: dis-redis-operator + app.kubernetes.io/name: dis-cache-operator diff --git a/services/dis-redis-operator/config/kind/applicationidentities.yaml b/services/dis-cache-operator/config/kind/applicationidentities.yaml similarity index 100% rename from services/dis-redis-operator/config/kind/applicationidentities.yaml rename to services/dis-cache-operator/config/kind/applicationidentities.yaml diff --git a/services/dis-redis-operator/config/kind/kustomization.yaml b/services/dis-cache-operator/config/kind/kustomization.yaml similarity index 70% rename from services/dis-redis-operator/config/kind/kustomization.yaml rename to services/dis-cache-operator/config/kind/kustomization.yaml index 9a0a614c9..4bd3d0928 100644 --- a/services/dis-redis-operator/config/kind/kustomization.yaml +++ b/services/dis-cache-operator/config/kind/kustomization.yaml @@ -10,5 +10,5 @@ patches: - path: manager_kind_patch.yaml target: kind: Deployment - name: dis-redis-operator-controller-manager - namespace: dis-redis-operator-system + name: dis-cache-operator-controller-manager + namespace: dis-cache-operator-system diff --git a/services/dis-redis-operator/config/kind/manager_kind_patch.yaml b/services/dis-cache-operator/config/kind/manager_kind_patch.yaml similarity index 93% rename from services/dis-redis-operator/config/kind/manager_kind_patch.yaml rename to services/dis-cache-operator/config/kind/manager_kind_patch.yaml index 95c367a72..3346f3dd6 100644 --- a/services/dis-redis-operator/config/kind/manager_kind_patch.yaml +++ b/services/dis-cache-operator/config/kind/manager_kind_patch.yaml @@ -1,8 +1,8 @@ apiVersion: apps/v1 kind: Deployment metadata: - name: dis-redis-operator-controller-manager - namespace: dis-redis-operator-system + name: dis-cache-operator-controller-manager + namespace: dis-cache-operator-system spec: template: spec: diff --git a/services/dis-redis-operator/config/kind/serviceaccounts.yaml b/services/dis-cache-operator/config/kind/serviceaccounts.yaml similarity index 100% rename from services/dis-redis-operator/config/kind/serviceaccounts.yaml rename to services/dis-cache-operator/config/kind/serviceaccounts.yaml diff --git a/services/dis-redis-operator/config/manager/kustomization.yaml b/services/dis-cache-operator/config/manager/kustomization.yaml similarity index 100% rename from services/dis-redis-operator/config/manager/kustomization.yaml rename to services/dis-cache-operator/config/manager/kustomization.yaml diff --git a/services/dis-redis-operator/config/manager/manager.yaml b/services/dis-cache-operator/config/manager/manager.yaml similarity index 89% rename from services/dis-redis-operator/config/manager/manager.yaml rename to services/dis-cache-operator/config/manager/manager.yaml index d07a2d8c8..3b54fc34c 100644 --- a/services/dis-redis-operator/config/manager/manager.yaml +++ b/services/dis-cache-operator/config/manager/manager.yaml @@ -3,7 +3,7 @@ kind: Namespace metadata: labels: control-plane: controller-manager - app.kubernetes.io/name: dis-redis-operator + app.kubernetes.io/name: dis-cache-operator app.kubernetes.io/managed-by: kustomize name: system --- @@ -14,13 +14,13 @@ metadata: namespace: system labels: control-plane: controller-manager - app.kubernetes.io/name: dis-redis-operator + app.kubernetes.io/name: dis-cache-operator app.kubernetes.io/managed-by: kustomize spec: selector: matchLabels: control-plane: controller-manager - app.kubernetes.io/name: dis-redis-operator + app.kubernetes.io/name: dis-cache-operator replicas: 1 template: metadata: @@ -28,7 +28,7 @@ spec: kubectl.kubernetes.io/default-container: manager labels: control-plane: controller-manager - app.kubernetes.io/name: dis-redis-operator + app.kubernetes.io/name: dis-cache-operator spec: securityContext: runAsNonRoot: true diff --git a/services/dis-redis-operator/config/network-policy/allow-metrics-traffic.yaml b/services/dis-cache-operator/config/network-policy/allow-metrics-traffic.yaml similarity index 81% rename from services/dis-redis-operator/config/network-policy/allow-metrics-traffic.yaml rename to services/dis-cache-operator/config/network-policy/allow-metrics-traffic.yaml index 82f7a6613..8ad461401 100644 --- a/services/dis-redis-operator/config/network-policy/allow-metrics-traffic.yaml +++ b/services/dis-cache-operator/config/network-policy/allow-metrics-traffic.yaml @@ -2,7 +2,7 @@ apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: labels: - app.kubernetes.io/name: dis-redis-operator + app.kubernetes.io/name: dis-cache-operator app.kubernetes.io/managed-by: kustomize name: allow-metrics-traffic namespace: system @@ -10,7 +10,7 @@ spec: podSelector: matchLabels: control-plane: controller-manager - app.kubernetes.io/name: dis-redis-operator + app.kubernetes.io/name: dis-cache-operator policyTypes: - Ingress ingress: diff --git a/services/dis-redis-operator/config/network-policy/kustomization.yaml b/services/dis-cache-operator/config/network-policy/kustomization.yaml similarity index 100% rename from services/dis-redis-operator/config/network-policy/kustomization.yaml rename to services/dis-cache-operator/config/network-policy/kustomization.yaml diff --git a/services/dis-redis-operator/config/prometheus/kustomization.yaml b/services/dis-cache-operator/config/prometheus/kustomization.yaml similarity index 100% rename from services/dis-redis-operator/config/prometheus/kustomization.yaml rename to services/dis-cache-operator/config/prometheus/kustomization.yaml diff --git a/services/dis-redis-operator/config/prometheus/monitor.yaml b/services/dis-cache-operator/config/prometheus/monitor.yaml similarity index 83% rename from services/dis-redis-operator/config/prometheus/monitor.yaml rename to services/dis-cache-operator/config/prometheus/monitor.yaml index 55791cc0f..8a6c6fa7e 100644 --- a/services/dis-redis-operator/config/prometheus/monitor.yaml +++ b/services/dis-cache-operator/config/prometheus/monitor.yaml @@ -3,7 +3,7 @@ kind: ServiceMonitor metadata: labels: control-plane: controller-manager - app.kubernetes.io/name: dis-redis-operator + app.kubernetes.io/name: dis-cache-operator app.kubernetes.io/managed-by: kustomize name: controller-manager-metrics-monitor namespace: system @@ -18,4 +18,4 @@ spec: selector: matchLabels: control-plane: controller-manager - app.kubernetes.io/name: dis-redis-operator + app.kubernetes.io/name: dis-cache-operator diff --git a/services/dis-redis-operator/config/rbac/kustomization.yaml b/services/dis-cache-operator/config/rbac/kustomization.yaml similarity index 100% rename from services/dis-redis-operator/config/rbac/kustomization.yaml rename to services/dis-cache-operator/config/rbac/kustomization.yaml diff --git a/services/dis-redis-operator/config/rbac/leader_election_role.yaml b/services/dis-cache-operator/config/rbac/leader_election_role.yaml similarity index 91% rename from services/dis-redis-operator/config/rbac/leader_election_role.yaml rename to services/dis-cache-operator/config/rbac/leader_election_role.yaml index cfc124391..628a56a33 100644 --- a/services/dis-redis-operator/config/rbac/leader_election_role.yaml +++ b/services/dis-cache-operator/config/rbac/leader_election_role.yaml @@ -2,7 +2,7 @@ apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: labels: - app.kubernetes.io/name: dis-redis-operator + app.kubernetes.io/name: dis-cache-operator app.kubernetes.io/managed-by: kustomize name: leader-election-role rules: diff --git a/services/dis-redis-operator/config/rbac/leader_election_role_binding.yaml b/services/dis-cache-operator/config/rbac/leader_election_role_binding.yaml similarity index 87% rename from services/dis-redis-operator/config/rbac/leader_election_role_binding.yaml rename to services/dis-cache-operator/config/rbac/leader_election_role_binding.yaml index 189cbc91a..819a74bd5 100644 --- a/services/dis-redis-operator/config/rbac/leader_election_role_binding.yaml +++ b/services/dis-cache-operator/config/rbac/leader_election_role_binding.yaml @@ -2,7 +2,7 @@ apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: labels: - app.kubernetes.io/name: dis-redis-operator + app.kubernetes.io/name: dis-cache-operator app.kubernetes.io/managed-by: kustomize name: leader-election-rolebinding roleRef: diff --git a/services/dis-redis-operator/config/rbac/metrics_auth_role.yaml b/services/dis-cache-operator/config/rbac/metrics_auth_role.yaml similarity index 100% rename from services/dis-redis-operator/config/rbac/metrics_auth_role.yaml rename to services/dis-cache-operator/config/rbac/metrics_auth_role.yaml diff --git a/services/dis-redis-operator/config/rbac/metrics_auth_role_binding.yaml b/services/dis-cache-operator/config/rbac/metrics_auth_role_binding.yaml similarity index 100% rename from services/dis-redis-operator/config/rbac/metrics_auth_role_binding.yaml rename to services/dis-cache-operator/config/rbac/metrics_auth_role_binding.yaml diff --git a/services/dis-redis-operator/config/rbac/metrics_reader_role.yaml b/services/dis-cache-operator/config/rbac/metrics_reader_role.yaml similarity index 100% rename from services/dis-redis-operator/config/rbac/metrics_reader_role.yaml rename to services/dis-cache-operator/config/rbac/metrics_reader_role.yaml diff --git a/services/dis-redis-operator/config/rbac/role.yaml b/services/dis-cache-operator/config/rbac/role.yaml similarity index 100% rename from services/dis-redis-operator/config/rbac/role.yaml rename to services/dis-cache-operator/config/rbac/role.yaml diff --git a/services/dis-redis-operator/config/rbac/role_binding.yaml b/services/dis-cache-operator/config/rbac/role_binding.yaml similarity index 87% rename from services/dis-redis-operator/config/rbac/role_binding.yaml rename to services/dis-cache-operator/config/rbac/role_binding.yaml index 606d0c7bb..00b054325 100644 --- a/services/dis-redis-operator/config/rbac/role_binding.yaml +++ b/services/dis-cache-operator/config/rbac/role_binding.yaml @@ -2,7 +2,7 @@ apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: labels: - app.kubernetes.io/name: dis-redis-operator + app.kubernetes.io/name: dis-cache-operator app.kubernetes.io/managed-by: kustomize name: manager-rolebinding roleRef: diff --git a/services/dis-redis-operator/config/rbac/service_account.yaml b/services/dis-cache-operator/config/rbac/service_account.yaml similarity index 75% rename from services/dis-redis-operator/config/rbac/service_account.yaml rename to services/dis-cache-operator/config/rbac/service_account.yaml index 899a8af5e..3cab674b9 100644 --- a/services/dis-redis-operator/config/rbac/service_account.yaml +++ b/services/dis-cache-operator/config/rbac/service_account.yaml @@ -2,7 +2,7 @@ apiVersion: v1 kind: ServiceAccount metadata: labels: - app.kubernetes.io/name: dis-redis-operator + app.kubernetes.io/name: dis-cache-operator app.kubernetes.io/managed-by: kustomize name: controller-manager namespace: system diff --git a/services/dis-redis-operator/config/samples/kustomization.yaml b/services/dis-cache-operator/config/samples/kustomization.yaml similarity index 100% rename from services/dis-redis-operator/config/samples/kustomization.yaml rename to services/dis-cache-operator/config/samples/kustomization.yaml diff --git a/services/dis-redis-operator/config/samples/redis_v1alpha1_redis.yaml b/services/dis-cache-operator/config/samples/redis_v1alpha1_redis.yaml similarity index 87% rename from services/dis-redis-operator/config/samples/redis_v1alpha1_redis.yaml rename to services/dis-cache-operator/config/samples/redis_v1alpha1_redis.yaml index 94307e1f2..5ab017853 100644 --- a/services/dis-redis-operator/config/samples/redis_v1alpha1_redis.yaml +++ b/services/dis-cache-operator/config/samples/redis_v1alpha1_redis.yaml @@ -2,7 +2,7 @@ apiVersion: redis.dis.altinn.cloud/v1alpha1 kind: Redis metadata: labels: - app.kubernetes.io/name: dis-redis-operator + app.kubernetes.io/name: dis-cache-operator app.kubernetes.io/managed-by: kustomize name: redis-sample spec: diff --git a/services/dis-redis-operator/config/samples/redis_v1alpha1_service_account_redis.yaml b/services/dis-cache-operator/config/samples/redis_v1alpha1_service_account_redis.yaml similarity index 88% rename from services/dis-redis-operator/config/samples/redis_v1alpha1_service_account_redis.yaml rename to services/dis-cache-operator/config/samples/redis_v1alpha1_service_account_redis.yaml index d923254bf..ce52366f1 100644 --- a/services/dis-redis-operator/config/samples/redis_v1alpha1_service_account_redis.yaml +++ b/services/dis-cache-operator/config/samples/redis_v1alpha1_service_account_redis.yaml @@ -2,7 +2,7 @@ apiVersion: redis.dis.altinn.cloud/v1alpha1 kind: Redis metadata: labels: - app.kubernetes.io/name: dis-redis-operator + app.kubernetes.io/name: dis-cache-operator app.kubernetes.io/managed-by: kustomize name: redis-service-account-sample spec: diff --git a/services/dis-redis-operator/go.mod b/services/dis-cache-operator/go.mod similarity index 98% rename from services/dis-redis-operator/go.mod rename to services/dis-cache-operator/go.mod index 48cbb82cc..ccce9e37b 100644 --- a/services/dis-redis-operator/go.mod +++ b/services/dis-cache-operator/go.mod @@ -1,4 +1,4 @@ -module github.com/Altinn/altinn-platform/services/dis-redis-operator +module github.com/Altinn/altinn-platform/services/dis-cache-operator go 1.26.3 diff --git a/services/dis-redis-operator/go.sum b/services/dis-cache-operator/go.sum similarity index 100% rename from services/dis-redis-operator/go.sum rename to services/dis-cache-operator/go.sum diff --git a/services/dis-redis-operator/hack/boilerplate.go.txt b/services/dis-cache-operator/hack/boilerplate.go.txt similarity index 100% rename from services/dis-redis-operator/hack/boilerplate.go.txt rename to services/dis-cache-operator/hack/boilerplate.go.txt diff --git a/services/dis-redis-operator/internal/config/config.go b/services/dis-cache-operator/internal/config/config.go similarity index 100% rename from services/dis-redis-operator/internal/config/config.go rename to services/dis-cache-operator/internal/config/config.go diff --git a/services/dis-redis-operator/internal/config/config_test.go b/services/dis-cache-operator/internal/config/config_test.go similarity index 100% rename from services/dis-redis-operator/internal/config/config_test.go rename to services/dis-cache-operator/internal/config/config_test.go diff --git a/services/dis-redis-operator/internal/controller/redis_auth_watch.go b/services/dis-cache-operator/internal/controller/redis_auth_watch.go similarity index 94% rename from services/dis-redis-operator/internal/controller/redis_auth_watch.go rename to services/dis-cache-operator/internal/controller/redis_auth_watch.go index ad76dfdd3..b16ab5984 100644 --- a/services/dis-redis-operator/internal/controller/redis_auth_watch.go +++ b/services/dis-cache-operator/internal/controller/redis_auth_watch.go @@ -3,7 +3,7 @@ package controller import ( "context" - redisv1alpha1 "github.com/Altinn/altinn-platform/services/dis-redis-operator/api/v1alpha1" + redisv1alpha1 "github.com/Altinn/altinn-platform/services/dis-cache-operator/api/v1alpha1" "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" diff --git a/services/dis-redis-operator/internal/controller/redis_controller.go b/services/dis-cache-operator/internal/controller/redis_controller.go similarity index 98% rename from services/dis-redis-operator/internal/controller/redis_controller.go rename to services/dis-cache-operator/internal/controller/redis_controller.go index 65ec1b9cd..b7e8a15f1 100644 --- a/services/dis-redis-operator/internal/controller/redis_controller.go +++ b/services/dis-cache-operator/internal/controller/redis_controller.go @@ -5,10 +5,10 @@ import ( "maps" "time" + redisv1alpha1 "github.com/Altinn/altinn-platform/services/dis-cache-operator/api/v1alpha1" + "github.com/Altinn/altinn-platform/services/dis-cache-operator/internal/config" + redispkg "github.com/Altinn/altinn-platform/services/dis-cache-operator/internal/redis" identityv1alpha1 "github.com/Altinn/altinn-platform/services/dis-identity-operator/api/v1alpha1" - redisv1alpha1 "github.com/Altinn/altinn-platform/services/dis-redis-operator/api/v1alpha1" - "github.com/Altinn/altinn-platform/services/dis-redis-operator/internal/config" - redispkg "github.com/Altinn/altinn-platform/services/dis-redis-operator/internal/redis" cachev1 "github.com/Azure/azure-service-operator/v2/api/cache/v1api20250401" pev1 "github.com/Azure/azure-service-operator/v2/api/network/v1api20220701" networkv1 "github.com/Azure/azure-service-operator/v2/api/network/v1api20240601" diff --git a/services/dis-redis-operator/internal/controller/redis_controller_network.go b/services/dis-cache-operator/internal/controller/redis_controller_network.go similarity index 92% rename from services/dis-redis-operator/internal/controller/redis_controller_network.go rename to services/dis-cache-operator/internal/controller/redis_controller_network.go index c7830f5b5..89599373f 100644 --- a/services/dis-redis-operator/internal/controller/redis_controller_network.go +++ b/services/dis-cache-operator/internal/controller/redis_controller_network.go @@ -4,8 +4,8 @@ import ( "context" "fmt" - redisv1alpha1 "github.com/Altinn/altinn-platform/services/dis-redis-operator/api/v1alpha1" - redispkg "github.com/Altinn/altinn-platform/services/dis-redis-operator/internal/redis" + redisv1alpha1 "github.com/Altinn/altinn-platform/services/dis-cache-operator/api/v1alpha1" + redispkg "github.com/Altinn/altinn-platform/services/dis-cache-operator/internal/redis" networkv1 "github.com/Azure/azure-service-operator/v2/api/network/v1api20240601" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/types" @@ -14,7 +14,7 @@ import ( // ensureSharedPrivateDNS get-or-creates the shared privatelink.redis.azure.net zone and AKS VNet link. // These are shared across all Redis CRs and are NOT owner-referenced to any single CR — instead they are -// label-managed via redis.dis.altinn.cloud/managed-by=dis-redis-operator. +// label-managed via redis.dis.altinn.cloud/managed-by=dis-cache-operator. func (r *RedisReconciler) ensureSharedPrivateDNS(ctx context.Context, redisObj *redisv1alpha1.Redis) error { logger := log.FromContext(ctx).WithValues("redis", types.NamespacedName{Namespace: redisObj.Namespace, Name: redisObj.Name}) diff --git a/services/dis-redis-operator/internal/controller/redis_controller_role.go b/services/dis-cache-operator/internal/controller/redis_controller_role.go similarity index 100% rename from services/dis-redis-operator/internal/controller/redis_controller_role.go rename to services/dis-cache-operator/internal/controller/redis_controller_role.go diff --git a/services/dis-redis-operator/internal/controller/redis_controller_status.go b/services/dis-cache-operator/internal/controller/redis_controller_status.go similarity index 97% rename from services/dis-redis-operator/internal/controller/redis_controller_status.go rename to services/dis-cache-operator/internal/controller/redis_controller_status.go index 618ee6146..0f8865c13 100644 --- a/services/dis-redis-operator/internal/controller/redis_controller_status.go +++ b/services/dis-cache-operator/internal/controller/redis_controller_status.go @@ -4,8 +4,8 @@ import ( "context" "fmt" - redisv1alpha1 "github.com/Altinn/altinn-platform/services/dis-redis-operator/api/v1alpha1" - redispkg "github.com/Altinn/altinn-platform/services/dis-redis-operator/internal/redis" + redisv1alpha1 "github.com/Altinn/altinn-platform/services/dis-cache-operator/api/v1alpha1" + redispkg "github.com/Altinn/altinn-platform/services/dis-cache-operator/internal/redis" cachev1 "github.com/Azure/azure-service-operator/v2/api/cache/v1api20250401" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) diff --git a/services/dis-redis-operator/internal/controller/redis_controller_test.go b/services/dis-cache-operator/internal/controller/redis_controller_test.go similarity index 100% rename from services/dis-redis-operator/internal/controller/redis_controller_test.go rename to services/dis-cache-operator/internal/controller/redis_controller_test.go diff --git a/services/dis-redis-operator/internal/controller/suite_test.go b/services/dis-cache-operator/internal/controller/suite_test.go similarity index 100% rename from services/dis-redis-operator/internal/controller/suite_test.go rename to services/dis-cache-operator/internal/controller/suite_test.go diff --git a/services/dis-redis-operator/internal/redis/builders.go b/services/dis-cache-operator/internal/redis/builders.go similarity index 98% rename from services/dis-redis-operator/internal/redis/builders.go rename to services/dis-cache-operator/internal/redis/builders.go index b12748219..cbb0db3b3 100644 --- a/services/dis-redis-operator/internal/redis/builders.go +++ b/services/dis-cache-operator/internal/redis/builders.go @@ -5,8 +5,8 @@ import ( "maps" "strings" - redisv1alpha1 "github.com/Altinn/altinn-platform/services/dis-redis-operator/api/v1alpha1" - "github.com/Altinn/altinn-platform/services/dis-redis-operator/internal/config" + redisv1alpha1 "github.com/Altinn/altinn-platform/services/dis-cache-operator/api/v1alpha1" + "github.com/Altinn/altinn-platform/services/dis-cache-operator/internal/config" cachev1 "github.com/Azure/azure-service-operator/v2/api/cache/v1api20250401" pev1 "github.com/Azure/azure-service-operator/v2/api/network/v1api20220701" networkv1 "github.com/Azure/azure-service-operator/v2/api/network/v1api20240601" diff --git a/services/dis-redis-operator/internal/redis/builders_test.go b/services/dis-cache-operator/internal/redis/builders_test.go similarity index 96% rename from services/dis-redis-operator/internal/redis/builders_test.go rename to services/dis-cache-operator/internal/redis/builders_test.go index a80716438..3c38ac1e7 100644 --- a/services/dis-redis-operator/internal/redis/builders_test.go +++ b/services/dis-cache-operator/internal/redis/builders_test.go @@ -3,8 +3,8 @@ package redis import ( "testing" - redisv1alpha1 "github.com/Altinn/altinn-platform/services/dis-redis-operator/api/v1alpha1" - "github.com/Altinn/altinn-platform/services/dis-redis-operator/internal/config" + redisv1alpha1 "github.com/Altinn/altinn-platform/services/dis-cache-operator/api/v1alpha1" + "github.com/Altinn/altinn-platform/services/dis-cache-operator/internal/config" cachev1 "github.com/Azure/azure-service-operator/v2/api/cache/v1api20250401" ) diff --git a/services/dis-redis-operator/internal/redis/identity.go b/services/dis-cache-operator/internal/redis/identity.go similarity index 98% rename from services/dis-redis-operator/internal/redis/identity.go rename to services/dis-cache-operator/internal/redis/identity.go index 8a7347318..520712c02 100644 --- a/services/dis-redis-operator/internal/redis/identity.go +++ b/services/dis-cache-operator/internal/redis/identity.go @@ -5,8 +5,8 @@ import ( "fmt" "strings" + redisv1alpha1 "github.com/Altinn/altinn-platform/services/dis-cache-operator/api/v1alpha1" identityv1alpha1 "github.com/Altinn/altinn-platform/services/dis-identity-operator/api/v1alpha1" - redisv1alpha1 "github.com/Altinn/altinn-platform/services/dis-redis-operator/api/v1alpha1" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" diff --git a/services/dis-redis-operator/internal/redis/identity_test.go b/services/dis-cache-operator/internal/redis/identity_test.go similarity index 98% rename from services/dis-redis-operator/internal/redis/identity_test.go rename to services/dis-cache-operator/internal/redis/identity_test.go index 70d3599a5..6f8fb0dbf 100644 --- a/services/dis-redis-operator/internal/redis/identity_test.go +++ b/services/dis-cache-operator/internal/redis/identity_test.go @@ -4,8 +4,8 @@ import ( "context" "testing" + redisv1alpha1 "github.com/Altinn/altinn-platform/services/dis-cache-operator/api/v1alpha1" identityv1alpha1 "github.com/Altinn/altinn-platform/services/dis-identity-operator/api/v1alpha1" - redisv1alpha1 "github.com/Altinn/altinn-platform/services/dis-redis-operator/api/v1alpha1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" diff --git a/services/dis-redis-operator/internal/redis/labels.go b/services/dis-cache-operator/internal/redis/labels.go similarity index 90% rename from services/dis-redis-operator/internal/redis/labels.go rename to services/dis-cache-operator/internal/redis/labels.go index 36b3a70dc..d828e0102 100644 --- a/services/dis-redis-operator/internal/redis/labels.go +++ b/services/dis-cache-operator/internal/redis/labels.go @@ -6,5 +6,5 @@ const ( // ManagedByLabel marks shared resources (DNS zone, VNet link) as operator-managed. ManagedByLabel = "redis.dis.altinn.cloud/managed-by" // ManagedByValue is the canonical operator identifier. - ManagedByValue = "dis-redis-operator" + ManagedByValue = "dis-cache-operator" ) diff --git a/services/dis-redis-operator/internal/redis/naming.go b/services/dis-cache-operator/internal/redis/naming.go similarity index 100% rename from services/dis-redis-operator/internal/redis/naming.go rename to services/dis-cache-operator/internal/redis/naming.go diff --git a/services/dis-redis-operator/internal/redis/naming_test.go b/services/dis-cache-operator/internal/redis/naming_test.go similarity index 100% rename from services/dis-redis-operator/internal/redis/naming_test.go rename to services/dis-cache-operator/internal/redis/naming_test.go diff --git a/services/dis-redis-operator/internal/redis/status.go b/services/dis-cache-operator/internal/redis/status.go similarity index 96% rename from services/dis-redis-operator/internal/redis/status.go rename to services/dis-cache-operator/internal/redis/status.go index a7030a4c5..a1fa6eb60 100644 --- a/services/dis-redis-operator/internal/redis/status.go +++ b/services/dis-cache-operator/internal/redis/status.go @@ -3,7 +3,7 @@ package redis import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - redisv1alpha1 "github.com/Altinn/altinn-platform/services/dis-redis-operator/api/v1alpha1" + redisv1alpha1 "github.com/Altinn/altinn-platform/services/dis-cache-operator/api/v1alpha1" asoconditions "github.com/Azure/azure-service-operator/v2/pkg/genruntime/conditions" ) diff --git a/services/dis-redis-operator/version.txt b/services/dis-cache-operator/version.txt similarity index 100% rename from services/dis-redis-operator/version.txt rename to services/dis-cache-operator/version.txt From 32aed92863e5db33b3b5c32808e8e8e3812b1498 Mon Sep 17 00:00:00 2001 From: Are Almaas Date: Wed, 20 May 2026 10:18:37 -0300 Subject: [PATCH 04/11] docs(dis-cache-operator): refresh PR description for rename and review fixes Co-Authored-By: Claude Opus 4.7 (1M context) --- pr_description.md | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/pr_description.md b/pr_description.md index ea0e7fa75..ff7d6528d 100644 --- a/pr_description.md +++ b/pr_description.md @@ -4,6 +4,8 @@ This PR scaffolds a new operator, `dis-cache-operator`, that reconciles a `Redis See [RFC 0014](rfcs/0014-self-service-managed-redis.md) for the full design. +> The operator was initially scaffolded under the name `dis-redis-operator`; it was renamed to `dis-cache-operator` in this branch so the naming can later cover Valkey and other managed cache backends without another rename. + ## Feature Behavior (BDD) **Given** a `Redis` custom resource in the team namespace that references a ready `ApplicationIdentity` via `spec.identityRef`, @@ -17,7 +19,7 @@ See [RFC 0014](rfcs/0014-self-service-managed-redis.md) for the full design. **Given** the referenced identity is not yet ready (missing `status.principalId` or `Ready=True`), **When** the operator reconciles, -**Then** it sets `IdentityReady=False` with reason `IdentityNotReady`, leaves Azure resources untouched, and requeues after 5 seconds. +**Then** it sets `IdentityReady=False` with reason `IdentityNotReady`, emits dependent ASO conditions as `IdentityNotReady` (rather than reading stale state), leaves Azure resources untouched, and requeues after 5 seconds. **Given** the `Redis` CR specifies `spec.serviceAccountRef` instead of `spec.identityRef`, **When** the operator reconciles, @@ -27,6 +29,14 @@ See [RFC 0014](rfcs/0014-self-service-managed-redis.md) for the full design. **When** the operator observes the deletion, **Then** Kubernetes garbage collection cascades deletion to the owner-referenced cluster, database, and private endpoint resources, while the shared DNS zone and VNet link remain (they outlive any single CR). +**Given** the `PrivateDnsZonesVirtualNetworkLink` is not yet ready, +**When** the operator computes `PrivateDNSReady`, +**Then** it requires both the zone and the VNet link to be Ready before reporting True, so applications never see a True DNS condition while name resolution from AKS still fails. + +**Given** any of `ClusterReady`, `DatabaseReady`, `PrivateEndpointReady`, or `PrivateDNSReady` is not yet True, +**When** the reconcile loop completes, +**Then** the controller requeues after `provisioningRequeueDelay` instead of waiting only for an owned-resource watch event (the shared DNS zone is label-managed and never fires watches). + > Note: `AccessPolicyAssignment` reconciliation is deferred to a follow-up PR (the upstream ASO type is not yet available in v2.17.0). The `AccessPolicyReady` condition reports `Unknown` / `Pending` until then. ## ASCII Diagram @@ -79,3 +89,11 @@ See [RFC 0014](rfcs/0014-self-service-managed-redis.md) for the full design. │ └────────────────┘ │ │ workload identity) │ └──────────────────────┘ └──────────────────────┘ ``` + +## Test plan + +- [ ] `cd services/dis-cache-operator && make fmt vet test lint` — all green locally +- [ ] `make manifests` — CRD reflects the `RedisPersistence` `aof`/`rdb` mutual-exclusion XValidation rule +- [ ] CI: `dis-cache-lint-test.yml` runs golangci-lint and `make test` on the new path filter +- [ ] CI: `dis-cache-release.yml` builds the image on merge to main; release-please opens a `dis-cache-v0.1.0` PR +- [ ] Apply `config/samples/redis_v1alpha1_redis.yaml` against a Kind cluster; verify the CR is admitted and conditions surface as expected From 7cbc43f6c91ed45fec28692fc03ff5e81f902c8d Mon Sep 17 00:00:00 2001 From: Are Almaas Date: Wed, 20 May 2026 10:50:31 -0300 Subject: [PATCH 05/11] chore: untrack local .omc session state Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 3 +++ .../sessions/0f974381-8734-4756-95d3-bc013825cdcd.json | 8 -------- 2 files changed, 3 insertions(+), 8 deletions(-) delete mode 100644 services/dis-cache-operator/.omc/sessions/0f974381-8734-4756-95d3-bc013825cdcd.json diff --git a/.gitignore b/.gitignore index 71b26aeb2..f728daad0 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,6 @@ override.tf.json terraform.rc go.work go.work.sum + +# Local oh-my-claudecode session state +.omc/ diff --git a/services/dis-cache-operator/.omc/sessions/0f974381-8734-4756-95d3-bc013825cdcd.json b/services/dis-cache-operator/.omc/sessions/0f974381-8734-4756-95d3-bc013825cdcd.json deleted file mode 100644 index 9045cba1e..000000000 --- a/services/dis-cache-operator/.omc/sessions/0f974381-8734-4756-95d3-bc013825cdcd.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "session_id": "0f974381-8734-4756-95d3-bc013825cdcd", - "ended_at": "2026-05-19T21:24:55.756Z", - "reason": "other", - "agents_spawned": 0, - "agents_completed": 0, - "modes_used": [] -} \ No newline at end of file From 832ad60504833bd12dc9bb3995270da6ecd2b6fa Mon Sep 17 00:00:00 2001 From: Are Almaas Date: Wed, 20 May 2026 10:52:59 -0300 Subject: [PATCH 06/11] chore: remove pr_description.md from repo Co-Authored-By: Claude Opus 4.7 (1M context) --- pr_description.md | 99 ----------------------------------------------- 1 file changed, 99 deletions(-) delete mode 100644 pr_description.md diff --git a/pr_description.md b/pr_description.md deleted file mode 100644 index ff7d6528d..000000000 --- a/pr_description.md +++ /dev/null @@ -1,99 +0,0 @@ -# Add dis-cache-operator (RFC 0014) — self-service Azure Managed Redis - -This PR scaffolds a new operator, `dis-cache-operator`, that reconciles a `Redis` CR into Azure Managed Redis (`Microsoft.Cache/redisEnterprise`) via Azure Service Operator. It mirrors the proven patterns from `dis-vault-operator` (single-resource-per-CR, federated-identity-owned) and `dis-pgsql-operator` (private endpoint + shared private DNS + AKS VNet link). - -See [RFC 0014](rfcs/0014-self-service-managed-redis.md) for the full design. - -> The operator was initially scaffolded under the name `dis-redis-operator`; it was renamed to `dis-cache-operator` in this branch so the naming can later cover Valkey and other managed cache backends without another rename. - -## Feature Behavior (BDD) - -**Given** a `Redis` custom resource in the team namespace that references a ready `ApplicationIdentity` via `spec.identityRef`, -**When** the operator reconciles the CR, -**Then** it computes a deterministic Azure cluster name from `namespace + name + environment`, -**And** it creates ASO `RedisEnterprise` (cluster) and `RedisEnterpriseDatabase` resources with `accessKeysAuthentication=Disabled`, TLS-only client protocol by default, and port 10000, -**And** it creates an ASO `PrivateEndpoint` targeting the cluster in the configured AKS data subnet, -**And** it get-or-creates the shared `privatelink.redis.azure.net` private DNS zone and the AKS VNet link to it (label-managed, not owner-referenced to any single CR), -**And** it publishes status conditions `IdentityReady`, `ClusterReady`, `DatabaseReady`, `PrivateEndpointReady`, `PrivateDNSReady`, `AccessPolicyReady`, and an aggregated `Ready`, -**And** it populates `status.azureName`, `status.hostName`, `status.port`, `status.clusterResourceId`, `status.databaseResourceId`, and `status.ownerPrincipalId`. - -**Given** the referenced identity is not yet ready (missing `status.principalId` or `Ready=True`), -**When** the operator reconciles, -**Then** it sets `IdentityReady=False` with reason `IdentityNotReady`, emits dependent ASO conditions as `IdentityNotReady` (rather than reading stale state), leaves Azure resources untouched, and requeues after 5 seconds. - -**Given** the `Redis` CR specifies `spec.serviceAccountRef` instead of `spec.identityRef`, -**When** the operator reconciles, -**Then** it resolves the principal from the workload-identity annotations (`azure.workload.identity/client-id` and `dis.altinn.cloud/principal-id`) on the referenced `ServiceAccount`. - -**Given** the `Redis` CR is deleted, -**When** the operator observes the deletion, -**Then** Kubernetes garbage collection cascades deletion to the owner-referenced cluster, database, and private endpoint resources, while the shared DNS zone and VNet link remain (they outlive any single CR). - -**Given** the `PrivateDnsZonesVirtualNetworkLink` is not yet ready, -**When** the operator computes `PrivateDNSReady`, -**Then** it requires both the zone and the VNet link to be Ready before reporting True, so applications never see a True DNS condition while name resolution from AKS still fails. - -**Given** any of `ClusterReady`, `DatabaseReady`, `PrivateEndpointReady`, or `PrivateDNSReady` is not yet True, -**When** the reconcile loop completes, -**Then** the controller requeues after `provisioningRequeueDelay` instead of waiting only for an owned-resource watch event (the shared DNS zone is label-managed and never fires watches). - -> Note: `AccessPolicyAssignment` reconciliation is deferred to a follow-up PR (the upstream ASO type is not yet available in v2.17.0). The `AccessPolicyReady` condition reports `Unknown` / `Pending` until then. - -## ASCII Diagram - -``` - ┌──────────────────────────────┐ - │ Team namespace │ - │ │ - │ Redis CR ──ref──> AppIdent. │ - │ │ │ │ - │ │ (controller │ │ - │ │ resolves │ │ - │ │ principalId) │ │ - └─────┼──────────────────┼─────┘ - │ │ - ▼ │ - ┌────────────────────────────┐ │ - │ dis-cache-operator │ │ - └─────┬───────────┬──────────┘ │ - │ │ │ - owns: ┌────▼───┐ ┌────▼──────┐ │ - │ ASO │ │ ASO │ │ - │ Cluster│ │ Database │ │ - └────┬───┘ └─────┬─────┘ │ - │ │ │ - ┌─────────▼──┐ ┌────▼─────────┐ │ - │ ASO │ │ (future PR) │ │ - │ Private │ │ Access │ │ - │ Endpoint │ │ Policy │ │ - └─────┬──────┘ │ Assignment │ │ - │ └──────────────┘ │ - │ │ - ▼ │ - ┌─────────────────────────────────────┐ │ - │ shared (label-managed, namespace- │ │ - │ scoped, not owner-ref'd to any CR): │ │ - │ - PrivateDnsZone: │ │ - │ privatelink.redis.azure.net │ │ - │ - PrivateDnsZonesVirtualNetworkLink │ │ - │ → AKS VNet │ │ - └─────────────────┬───────────────────┘ │ - │ │ - ▼ │ - ┌──────────────────────┐ │ - │ Azure subscription │ │ - │ ┌────────────────┐ │ ┌──────────▼───────────┐ - │ │ RedisEnterprise│◀──┼───│ Workload pod │ - │ │ + Database │ │ │ (TLS 10000, Entra │ - │ │ + Priv. EP │ │ │ token via federated │ - │ └────────────────┘ │ │ workload identity) │ - └──────────────────────┘ └──────────────────────┘ -``` - -## Test plan - -- [ ] `cd services/dis-cache-operator && make fmt vet test lint` — all green locally -- [ ] `make manifests` — CRD reflects the `RedisPersistence` `aof`/`rdb` mutual-exclusion XValidation rule -- [ ] CI: `dis-cache-lint-test.yml` runs golangci-lint and `make test` on the new path filter -- [ ] CI: `dis-cache-release.yml` builds the image on merge to main; release-please opens a `dis-cache-v0.1.0` PR -- [ ] Apply `config/samples/redis_v1alpha1_redis.yaml` against a Kind cluster; verify the CR is admitted and conditions surface as expected From 0bb46d039a6fcf139fd51e26947d0290ddfad1f2 Mon Sep 17 00:00:00 2001 From: Are Almaas Date: Wed, 20 May 2026 11:01:30 -0300 Subject: [PATCH 07/11] fix(dis-cache-operator): address remaining review nits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - workflow: set persist-credentials: false on both checkout steps in dis-cache-lint-test.yml so GITHUB_TOKEN is not implicitly exposed to subsequent steps - Makefile: drop the leading '-' from the docker-buildx push command so publish failures propagate (the '-' on the create/rm cleanup steps is retained intentionally — those legitimately tolerate failures) Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/dis-cache-lint-test.yml | 4 ++++ services/dis-cache-operator/Makefile | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/dis-cache-lint-test.yml b/.github/workflows/dis-cache-lint-test.yml index 0ddcdac53..d613bae06 100644 --- a/.github/workflows/dis-cache-lint-test.yml +++ b/.github/workflows/dis-cache-lint-test.yml @@ -27,6 +27,8 @@ jobs: steps: - name: Clone the code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Setup Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 @@ -48,6 +50,8 @@ jobs: steps: - name: Clone the code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Setup Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 diff --git a/services/dis-cache-operator/Makefile b/services/dis-cache-operator/Makefile index f8b008e62..412dafb46 100644 --- a/services/dis-cache-operator/Makefile +++ b/services/dis-cache-operator/Makefile @@ -198,7 +198,7 @@ docker-buildx: ## Build and push docker image for the manager for cross-platform sed -e '1 s/\(^FROM\)/FROM --platform=\$$\{BUILDPLATFORM\}/; t' -e ' 1,// s//FROM --platform=\$$\{BUILDPLATFORM\}/' Dockerfile > Dockerfile.cross - $(CONTAINER_TOOL) buildx create --name dis-cache-operator-builder $(CONTAINER_TOOL) buildx use dis-cache-operator-builder - - $(CONTAINER_TOOL) buildx build --push --platform=$(PLATFORMS) --tag ${IMG} -f Dockerfile.cross . + $(CONTAINER_TOOL) buildx build --push --platform=$(PLATFORMS) --tag ${IMG} -f Dockerfile.cross . - $(CONTAINER_TOOL) buildx rm dis-cache-operator-builder rm Dockerfile.cross From 0296eb2d414a0d5954be90c11afea558e00f74b1 Mon Sep 17 00:00:00 2001 From: Are Almaas Date: Wed, 20 May 2026 11:10:48 -0300 Subject: [PATCH 08/11] fix(dis-cache-operator): unblock CI verify-deps and trivy scan - verify-deps cleanup now chmod -R u+w the temp module cache before rm, since go mod download writes files read-only and the trap-rm previously failed with Permission denied in CI - add empty .trivyignore so the image-scan workflow can find the expected ignorefile (mirrors dis-vault-operator) Co-Authored-By: Claude Opus 4.7 (1M context) --- services/dis-cache-operator/.trivyignore | 0 services/dis-cache-operator/Makefile | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 services/dis-cache-operator/.trivyignore diff --git a/services/dis-cache-operator/.trivyignore b/services/dis-cache-operator/.trivyignore new file mode 100644 index 000000000..e69de29bb diff --git a/services/dis-cache-operator/Makefile b/services/dis-cache-operator/Makefile index 412dafb46..fe26e638c 100644 --- a/services/dis-cache-operator/Makefile +++ b/services/dis-cache-operator/Makefile @@ -73,7 +73,7 @@ tidy: ## Run go mod tidy. .PHONY: verify-deps verify-deps: ## Verify go.mod/go.sum resolve from a clean module cache. @tmp_modcache="$$(mktemp -d)"; \ - trap 'rm -rf "$$tmp_modcache"' EXIT INT TERM; \ + trap 'chmod -R u+w "$$tmp_modcache" 2>/dev/null; rm -rf "$$tmp_modcache"' EXIT INT TERM; \ echo "Verifying module pins with clean module cache: $$tmp_modcache"; \ GOWORK=off GOFLAGS=-mod=readonly GOMODCACHE="$$tmp_modcache" go mod download From 8238be5b07d1a6a15bd71127e23b01842a242487 Mon Sep 17 00:00:00 2001 From: Are Almaas Date: Wed, 20 May 2026 11:14:25 -0300 Subject: [PATCH 09/11] fix(dis-cache-operator): pin golang base image and bump grpc - Dockerfile: pin golang:1.26.3 to its OCI image-index digest (sha256:6df14f4a...) so the builder image is reproducible, matching the lakmus pattern - go.mod: bump google.golang.org/grpc v1.78.0 -> v1.79.3 to remediate CVE-2026-33186 (indirect, pulled by ASO/controller-runtime deps); cel.dev/expr carried up to v0.25.1 via go mod tidy Co-Authored-By: Claude Opus 4.7 (1M context) --- services/dis-cache-operator/Dockerfile | 2 +- services/dis-cache-operator/go.mod | 4 ++-- services/dis-cache-operator/go.sum | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/services/dis-cache-operator/Dockerfile b/services/dis-cache-operator/Dockerfile index 76f9201bf..302b5452c 100644 --- a/services/dis-cache-operator/Dockerfile +++ b/services/dis-cache-operator/Dockerfile @@ -1,5 +1,5 @@ # Build the manager binary -FROM golang:1.26.3 AS builder +FROM golang:1.26.3@sha256:6df14f4a4bc9d979a3721f488981e0d1b318006377e473ed23d026796f5f4c0a AS builder ARG TARGETOS ARG TARGETARCH diff --git a/services/dis-cache-operator/go.mod b/services/dis-cache-operator/go.mod index ccce9e37b..ff1040a53 100644 --- a/services/dis-cache-operator/go.mod +++ b/services/dis-cache-operator/go.mod @@ -14,7 +14,7 @@ require ( ) require ( - cel.dev/expr v0.24.0 // indirect + cel.dev/expr v0.25.1 // indirect github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect @@ -90,7 +90,7 @@ require ( gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect - google.golang.org/grpc v1.78.0 // indirect + google.golang.org/grpc v1.79.3 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect diff --git a/services/dis-cache-operator/go.sum b/services/dis-cache-operator/go.sum index 33d7e3c72..089efbdbd 100644 --- a/services/dis-cache-operator/go.sum +++ b/services/dis-cache-operator/go.sum @@ -1,5 +1,5 @@ -cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= -cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/Altinn/altinn-platform/services/dis-identity-operator v0.0.0-20260319083500-bd4f5f84c472 h1:NsJwNEiCeUqll7Eyc4hn8akSFqG91/vGt+Jd5mCC3e4= @@ -331,8 +331,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1: google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= -google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= -google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From 9f6e2897d6aaaf3fe2ac95dc7925acdc78333477 Mon Sep 17 00:00:00 2001 From: Are Almaas Date: Wed, 20 May 2026 15:34:45 -0300 Subject: [PATCH 10/11] fix(dis-cache-operator): bump OTel to v1.43.0 to fix HIGH CVEs Trivy image scan flagged two HIGH CVEs in the manager binary: - CVE-2026-29181 in go.opentelemetry.io/otel v1.40.0 (fixed in 1.41.0) - CVE-2026-39883 in go.opentelemetry.io/otel/sdk v1.40.0 (fixed in 1.43.0) Align with dis-pgsql-operator and dis-vault-operator, which already pin the OTel stack at v1.43.0. Co-Authored-By: Claude Opus 4.7 (1M context) --- services/dis-cache-operator/go.mod | 38 ++++++------- services/dis-cache-operator/go.sum | 88 +++++++++++++++--------------- 2 files changed, 63 insertions(+), 63 deletions(-) diff --git a/services/dis-cache-operator/go.mod b/services/dis-cache-operator/go.mod index ff1040a53..bc47bd762 100644 --- a/services/dis-cache-operator/go.mod +++ b/services/dis-cache-operator/go.mod @@ -44,7 +44,7 @@ require ( github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jellydator/ttlcache/v3 v3.4.0 // indirect github.com/josharian/intern v1.0.0 // indirect @@ -66,31 +66,31 @@ require ( github.com/x448/float16 v0.8.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect - go.opentelemetry.io/otel v1.40.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 // indirect - go.opentelemetry.io/otel/metric v1.40.0 // indirect - go.opentelemetry.io/otel/sdk v1.40.0 // indirect - go.opentelemetry.io/otel/trace v1.40.0 // indirect - go.opentelemetry.io/proto/otlp v1.9.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/sdk v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect - golang.org/x/mod v0.31.0 // indirect - golang.org/x/net v0.49.0 // indirect - golang.org/x/oauth2 v0.34.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/term v0.39.0 // indirect - golang.org/x/text v0.33.0 // indirect + golang.org/x/mod v0.33.0 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/term v0.41.0 // indirect + golang.org/x/text v0.35.0 // indirect golang.org/x/time v0.14.0 // indirect - golang.org/x/tools v0.40.0 // indirect + golang.org/x/tools v0.42.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect - google.golang.org/grpc v1.79.3 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/grpc v1.80.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect diff --git a/services/dis-cache-operator/go.sum b/services/dis-cache-operator/go.sum index 089efbdbd..f88e41d12 100644 --- a/services/dis-cache-operator/go.sum +++ b/services/dis-cache-operator/go.sum @@ -151,8 +151,8 @@ github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -275,22 +275,22 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= -go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= -go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 h1:DvJDOPmSWQHWywQS6lKL+pb8s3gBLOZUtw4N+mavW1I= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0/go.mod h1:EtekO9DEJb4/jRyN4v4Qjc2yA7AtfCBuz2FynRUWTXs= -go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= -go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= -go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= -go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= -go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= -go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= -go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= -go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= -go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= -go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -301,38 +301,38 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= -golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= -golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= -golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= -golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= -golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= -golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= -golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= -golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= -google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= -google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From bce97a9a946fe1bbeb4292357fdc81b234a9e0d4 Mon Sep 17 00:00:00 2001 From: Are Almaas Date: Wed, 20 May 2026 16:34:51 -0300 Subject: [PATCH 11/11] feat(dis-cache-operator): wire PrivateDnsZoneGroup and mirror pgsql DNS pattern The PE was being created but no PrivateEndpointsPrivateDnsZoneGroup bound it into the shared privatelink.redis.azure.net zone, so DNS resolution from AKS to the cluster's private IP would never work. This adds the missing per-CR DnsZoneGroup, owner-referenced to the Redis CR (cascades on delete), pointing at the shared zone via ARM ID. Also aligns the shared-DNS reconciliation with dis-pgsql-operator: - Use SyncSpecAndLabels for in-place drift detection instead of create-only get-or-create. - Tag shared resources with managed-by=dis-cache-operator. - Wire the new zone group into getSharedDNSReady so PrivateDNSReady only flips true once the full DNS path (zone + link + zone group) is ready. - Replace inline groupID literal with a named constant. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dis-cache-operator/config/rbac/role.yaml | 2 + .../internal/controller/redis_controller.go | 41 ++++++++++- .../controller/redis_controller_network.go | 22 +++++- .../internal/k8s/resource_sync.go | 37 ++++++++++ .../internal/redis/builders.go | 70 +++++++++++++++++-- .../internal/redis/builders_test.go | 29 ++++++++ .../internal/redis/labels.go | 2 + 7 files changed, 194 insertions(+), 9 deletions(-) create mode 100644 services/dis-cache-operator/internal/k8s/resource_sync.go diff --git a/services/dis-cache-operator/config/rbac/role.yaml b/services/dis-cache-operator/config/rbac/role.yaml index 0864928cf..a6ba6878c 100644 --- a/services/dis-cache-operator/config/rbac/role.yaml +++ b/services/dis-cache-operator/config/rbac/role.yaml @@ -48,6 +48,7 @@ rules: - privatednszones - privatednszonesvirtualnetworklinks - privateendpoints + - privateendpointsprivatednszonegroups verbs: - create - delete @@ -62,6 +63,7 @@ rules: - privatednszones/status - privatednszonesvirtualnetworklinks/status - privateendpoints/status + - privateendpointsprivatednszonegroups/status verbs: - get - patch diff --git a/services/dis-cache-operator/internal/controller/redis_controller.go b/services/dis-cache-operator/internal/controller/redis_controller.go index b7e8a15f1..29ba29f88 100644 --- a/services/dis-cache-operator/internal/controller/redis_controller.go +++ b/services/dis-cache-operator/internal/controller/redis_controller.go @@ -50,6 +50,8 @@ type RedisReconciler struct { // ASO: Network // +kubebuilder:rbac:groups=network.azure.com,resources=privateendpoints,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=network.azure.com,resources=privateendpoints/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=network.azure.com,resources=privateendpointsprivatednszonegroups,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=network.azure.com,resources=privateendpointsprivatednszonegroups/status,verbs=get;update;patch // +kubebuilder:rbac:groups=network.azure.com,resources=privatednszones,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=network.azure.com,resources=privatednszones/status,verbs=get;update;patch // +kubebuilder:rbac:groups=network.azure.com,resources=privatednszonesvirtualnetworklinks,verbs=get;list;watch;create;update;patch;delete @@ -121,6 +123,14 @@ func (r *RedisReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl if err := r.ensureSharedPrivateDNS(ctx, &redisObj); err != nil { return ctrl.Result{}, err } + + desiredZoneGroup, err := redispkg.BuildPrivateDnsZoneGroup(&redisObj, r.Config) + if err != nil { + return ctrl.Result{}, err + } + if err := r.upsertPrivateDnsZoneGroup(ctx, &redisObj, desiredZoneGroup); err != nil { + return ctrl.Result{}, err + } } cluster, clusterReady, err = r.getCurrentCluster(ctx, &redisObj) @@ -179,6 +189,7 @@ func (r *RedisReconciler) SetupWithManager(mgr ctrl.Manager) error { Owns(&cachev1.RedisEnterprise{}). Owns(&cachev1.RedisEnterpriseDatabase{}). Owns(&pev1.PrivateEndpoint{}). + Owns(&pev1.PrivateEndpointsPrivateDnsZoneGroup{}). Watches(&identityv1alpha1.ApplicationIdentity{}, handler.EnqueueRequestsFromMapFunc(r.mapApplicationIdentityToRedises)). Watches(&corev1.ServiceAccount{}, handler.EnqueueRequestsFromMapFunc(r.mapServiceAccountToRedises)). WithOptions(controller.Options{ @@ -226,6 +237,19 @@ func (r *RedisReconciler) upsertPrivateEndpoint(ctx context.Context, owner *redi return err } +func (r *RedisReconciler) upsertPrivateDnsZoneGroup(ctx context.Context, owner *redisv1alpha1.Redis, desired *pev1.PrivateEndpointsPrivateDnsZoneGroup) error { + current := &pev1.PrivateEndpointsPrivateDnsZoneGroup{} + current.SetName(desired.GetName()) + current.SetNamespace(desired.GetNamespace()) + + _, err := controllerutil.CreateOrUpdate(ctx, r.Client, current, func() error { + current.Labels = mergeStringMaps(current.Labels, desired.Labels) + current.Spec = desired.Spec + return ctrl.SetControllerReference(owner, current, r.Scheme) + }) + return err +} + func (r *RedisReconciler) getCurrentCluster(ctx context.Context, redisObj *redisv1alpha1.Redis) (*cachev1.RedisEnterprise, redispkg.ASOReadyCondition, error) { current := &cachev1.RedisEnterprise{} if err := r.Get(ctx, types.NamespacedName{ @@ -294,7 +318,22 @@ func (r *RedisReconciler) getSharedDNSReady(ctx context.Context, redisObj *redis } return redispkg.ASOReadyCondition{}, err } - return redispkg.FromASOConditions(link.Status.Conditions), nil + linkReady := redispkg.FromASOConditions(link.Status.Conditions) + if !linkReady.Found || linkReady.Status != metav1.ConditionTrue { + return linkReady, nil + } + + group := &pev1.PrivateEndpointsPrivateDnsZoneGroup{} + if err := r.Get(ctx, types.NamespacedName{ + Name: redispkg.PrivateDNSZoneGroupKubernetesName(redisObj.Name), + Namespace: redisObj.Namespace, + }, group); err != nil { + if apierrors.IsNotFound(err) { + return redispkg.ASOReadyCondition{}, nil + } + return redispkg.ASOReadyCondition{}, err + } + return redispkg.FromASOConditions(group.Status.Conditions), nil } func setStatusCondition(redisObj *redisv1alpha1.Redis, condition metav1.Condition) bool { diff --git a/services/dis-cache-operator/internal/controller/redis_controller_network.go b/services/dis-cache-operator/internal/controller/redis_controller_network.go index 89599373f..7b49e94ab 100644 --- a/services/dis-cache-operator/internal/controller/redis_controller_network.go +++ b/services/dis-cache-operator/internal/controller/redis_controller_network.go @@ -5,6 +5,7 @@ import ( "fmt" redisv1alpha1 "github.com/Altinn/altinn-platform/services/dis-cache-operator/api/v1alpha1" + k8sutil "github.com/Altinn/altinn-platform/services/dis-cache-operator/internal/k8s" redispkg "github.com/Altinn/altinn-platform/services/dis-cache-operator/internal/redis" networkv1 "github.com/Azure/azure-service-operator/v2/api/network/v1api20240601" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -14,7 +15,8 @@ import ( // ensureSharedPrivateDNS get-or-creates the shared privatelink.redis.azure.net zone and AKS VNet link. // These are shared across all Redis CRs and are NOT owner-referenced to any single CR — instead they are -// label-managed via redis.dis.altinn.cloud/managed-by=dis-cache-operator. +// label-managed via redis.dis.altinn.cloud/managed-by=dis-cache-operator. Spec/label drift on existing +// resources is reconciled on each pass, mirroring the dis-pgsql-operator DNS reconciliation pattern. func (r *RedisReconciler) ensureSharedPrivateDNS(ctx context.Context, redisObj *redisv1alpha1.Redis) error { logger := log.FromContext(ctx).WithValues("redis", types.NamespacedName{Namespace: redisObj.Namespace, Name: redisObj.Name}) @@ -33,6 +35,15 @@ func (r *RedisReconciler) ensureSharedDNSZone(ctx context.Context, namespace str current := &networkv1.PrivateDnsZone{} err := r.Get(ctx, types.NamespacedName{Name: desired.Name, Namespace: namespace}, current) if err == nil { + labels, updated := k8sutil.SyncSpecAndLabels(¤t.Spec, desired.Spec, current.Labels, desired.Labels) + if !updated { + return nil + } + current.Labels = labels + logger.Info("updating shared private DNS zone", "zoneName", desired.Name, "namespace", namespace) + if err := r.Update(ctx, current); err != nil { + return fmt.Errorf("update PrivateDnsZone %s/%s: %w", namespace, desired.Name, err) + } return nil } if !apierrors.IsNotFound(err) { @@ -55,6 +66,15 @@ func (r *RedisReconciler) ensureSharedVNetLink(ctx context.Context, namespace st current := &networkv1.PrivateDnsZonesVirtualNetworkLink{} err := r.Get(ctx, types.NamespacedName{Name: desired.Name, Namespace: namespace}, current) if err == nil { + labels, updated := k8sutil.SyncSpecAndLabels(¤t.Spec, desired.Spec, current.Labels, desired.Labels) + if !updated { + return nil + } + current.Labels = labels + logger.Info("updating shared private DNS VNet link", "linkName", desired.Name, "namespace", namespace) + if err := r.Update(ctx, current); err != nil { + return fmt.Errorf("update PrivateDnsZonesVirtualNetworkLink %s/%s: %w", namespace, desired.Name, err) + } return nil } if !apierrors.IsNotFound(err) { diff --git a/services/dis-cache-operator/internal/k8s/resource_sync.go b/services/dis-cache-operator/internal/k8s/resource_sync.go new file mode 100644 index 000000000..a3ad4b3ab --- /dev/null +++ b/services/dis-cache-operator/internal/k8s/resource_sync.go @@ -0,0 +1,37 @@ +package k8s + +import "k8s.io/apimachinery/pkg/api/equality" + +// SyncSpecAndLabels mutates an existing resource in-memory to match desired state. +// +// It compares existing vs desired spec, and ensures every desired label key/value is +// present. Missing or different desired labels are upserted. Existing labels that are +// not in desiredLabels are preserved. +// +// The returned bool is true when an API Update call is required to persist changes. +func SyncSpecAndLabels[S any]( + existingSpec *S, + desiredSpec S, + existingLabels map[string]string, + desiredLabels map[string]string, +) (map[string]string, bool) { + updated := false + + if !equality.Semantic.DeepEqual(*existingSpec, desiredSpec) { + *existingSpec = desiredSpec + updated = true + } + + if existingLabels == nil { + existingLabels = map[string]string{} + } + + for key, value := range desiredLabels { + if existingLabels[key] != value { + existingLabels[key] = value + updated = true + } + } + + return existingLabels, updated +} diff --git a/services/dis-cache-operator/internal/redis/builders.go b/services/dis-cache-operator/internal/redis/builders.go index cbb0db3b3..93c417f1b 100644 --- a/services/dis-cache-operator/internal/redis/builders.go +++ b/services/dis-cache-operator/internal/redis/builders.go @@ -24,12 +24,17 @@ const ( // DefaultDatabaseAzureName is the default database name used per cluster. DefaultDatabaseAzureName = "default" - clusterKubernetesSuffix = "cluster" - databaseKubernetesSuffix = "db" - privateEndpointSuffix = "pe" - privateDNSZoneGroupSuffix = "pdzg" - dnsZoneVNetLinkBaseName = "aks-link" - privateEndpointConnectionID = "redis-enterprise" + clusterKubernetesSuffix = "cluster" + databaseKubernetesSuffix = "db" + privateEndpointSuffix = "pe" + privateDNSZoneGroupSuffix = "pdzg" + dnsZoneVNetLinkBaseName = "aks-link" + + // privateLinkGroupID is the Azure subresource ID for a Redis Enterprise private endpoint. + privateLinkGroupID = "redisEnterprise" + + // privateDnsZoneConfigName is the per-CR config name within the PrivateEndpointsPrivateDnsZoneGroup. + privateDnsZoneConfigName = "redis" ) // ClusterKubernetesName returns the Kubernetes name used for the ASO RedisEnterprise CR. @@ -187,7 +192,7 @@ func BuildPrivateEndpoint(r *redisv1alpha1.Redis, cfg config.OperatorConfig, clu location := cfg.Location connName := PrivateEndpointKubernetesName(r.Name) - groupID := "redisEnterprise" + groupID := privateLinkGroupID pe := &pev1.PrivateEndpoint{ ObjectMeta: metav1.ObjectMeta{ Name: PrivateEndpointKubernetesName(r.Name), @@ -224,6 +229,51 @@ func BuildPrivateEndpoint(r *redisv1alpha1.Redis, cfg config.OperatorConfig, clu return pe, nil } +// SharedPrivateDNSZoneARMID returns the ARM ID of the shared privatelink.redis.azure.net zone. +func SharedPrivateDNSZoneARMID(cfg config.OperatorConfig) string { + return fmt.Sprintf( + "/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/privateDnsZones/%s", + cfg.SubscriptionID, + cfg.DNSZoneResourceGroup, + RedisPrivateLinkZoneName, + ) +} + +// BuildPrivateDnsZoneGroup returns the desired PrivateEndpointsPrivateDnsZoneGroup binding the PE +// A-record into the shared privatelink.redis.azure.net zone. +func BuildPrivateDnsZoneGroup(r *redisv1alpha1.Redis, cfg config.OperatorConfig) (*pev1.PrivateEndpointsPrivateDnsZoneGroup, error) { + if r == nil { + return nil, fmt.Errorf("redis must not be nil") + } + + configName := privateDnsZoneConfigName + zoneARMID := SharedPrivateDNSZoneARMID(cfg) + + return &pev1.PrivateEndpointsPrivateDnsZoneGroup{ + ObjectMeta: metav1.ObjectMeta{ + Name: PrivateDNSZoneGroupKubernetesName(r.Name), + Namespace: r.Namespace, + Labels: map[string]string{ + ManagedResourceOwnerLabel: r.Name, + }, + }, + Spec: pev1.PrivateEndpointsPrivateDnsZoneGroup_Spec{ + AzureName: PrivateDNSZoneGroupKubernetesName(r.Name), + Owner: &genruntime.KnownResourceReference{ + Name: PrivateEndpointKubernetesName(r.Name), + }, + PrivateDnsZoneConfigs: []pev1.PrivateDnsZoneConfig{ + { + Name: &configName, + PrivateDnsZoneReference: &genruntime.ResourceReference{ + ARMID: zoneARMID, + }, + }, + }, + }, + }, nil +} + // BuildSharedPrivateDNSZone returns the desired shared privatelink.redis.azure.net zone. func BuildSharedPrivateDNSZone(namespace string, cfg config.OperatorConfig) *networkv1.PrivateDnsZone { loc := "global" @@ -241,6 +291,9 @@ func BuildSharedPrivateDNSZone(namespace string, cfg config.OperatorConfig) *net Owner: &genruntime.KnownResourceReference{ ARMID: fmt.Sprintf("/subscriptions/%s/resourceGroups/%s", cfg.SubscriptionID, cfg.DNSZoneResourceGroup), }, + Tags: map[string]string{ + ManagedByTagKey: ManagedByValue, + }, }, } } @@ -270,6 +323,9 @@ func BuildSharedVNetLink(namespace string, cfg config.OperatorConfig) *networkv1 ARMID: cfg.AKSVNetID, }, }, + Tags: map[string]string{ + ManagedByTagKey: ManagedByValue, + }, }, } } diff --git a/services/dis-cache-operator/internal/redis/builders_test.go b/services/dis-cache-operator/internal/redis/builders_test.go index 3c38ac1e7..6c3913b63 100644 --- a/services/dis-cache-operator/internal/redis/builders_test.go +++ b/services/dis-cache-operator/internal/redis/builders_test.go @@ -131,6 +131,35 @@ func TestBuildPrivateEndpoint(t *testing.T) { } } +func TestBuildPrivateDnsZoneGroup(t *testing.T) { + t.Parallel() + + r := testRedis() + r.Name = testRedisName + r.Namespace = testNamespace + + group, err := BuildPrivateDnsZoneGroup(r, testConfig()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if group.Spec.Owner == nil || group.Spec.Owner.Name != PrivateEndpointKubernetesName(testRedisName) { + t.Fatalf("expected owner to reference the PE, got %#v", group.Spec.Owner) + } + if len(group.Spec.PrivateDnsZoneConfigs) != 1 { + t.Fatalf("expected exactly one zone config, got %d", len(group.Spec.PrivateDnsZoneConfigs)) + } + cfg := group.Spec.PrivateDnsZoneConfigs[0] + if cfg.PrivateDnsZoneReference == nil || cfg.PrivateDnsZoneReference.ARMID == "" { + t.Fatalf("expected ARM ID reference to the shared zone, got %#v", cfg.PrivateDnsZoneReference) + } + if cfg.PrivateDnsZoneReference.ARMID != SharedPrivateDNSZoneARMID(testConfig()) { + t.Fatalf("expected ARM ID %q, got %q", SharedPrivateDNSZoneARMID(testConfig()), cfg.PrivateDnsZoneReference.ARMID) + } + if group.Labels[ManagedResourceOwnerLabel] != testRedisName { + t.Fatalf("expected owner label on zone group") + } +} + func TestBuildSharedPrivateDNSZone(t *testing.T) { t.Parallel() diff --git a/services/dis-cache-operator/internal/redis/labels.go b/services/dis-cache-operator/internal/redis/labels.go index d828e0102..c66ae44cc 100644 --- a/services/dis-cache-operator/internal/redis/labels.go +++ b/services/dis-cache-operator/internal/redis/labels.go @@ -7,4 +7,6 @@ const ( ManagedByLabel = "redis.dis.altinn.cloud/managed-by" // ManagedByValue is the canonical operator identifier. ManagedByValue = "dis-cache-operator" + // ManagedByTagKey is the Azure tag key used on shared resources to identify the managing operator. + ManagedByTagKey = "managed-by" )