diff --git a/.github/workflows/dis-cache-lint-test.yml b/.github/workflows/dis-cache-lint-test.yml new file mode 100644 index 000000000..d613bae06 --- /dev/null +++ b/.github/workflows/dis-cache-lint-test.yml @@ -0,0 +1,65 @@ +name: Dis Redis Lint and Test + +on: + push: + branches: + - main + paths: + - services/dis-cache-operator/** + - .github/workflows/dis-cache-lint-test.yml + pull_request: + branches: + - main + paths: + - services/dis-cache-operator/** + - .github/workflows/dis-cache-lint-test.yml + +permissions: + contents: read + +jobs: + lint: + name: Run linter on Ubuntu + runs-on: ubuntu-latest + defaults: + run: + working-directory: services/dis-cache-operator + 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 + with: + 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-cache-operator + test: + name: Run tests on Ubuntu + runs-on: ubuntu-latest + defaults: + run: + working-directory: services/dis-cache-operator + 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 + with: + go-version-file: "services/dis-cache-operator/go.mod" + cache-dependency-path: "services/dis-cache-operator/go.sum" + + - name: Running Tests + run: | + make verify-deps + make test diff --git a/.github/workflows/dis-cache-release.yml b/.github/workflows/dis-cache-release.yml new file mode 100644 index 000000000..07f1ec85d --- /dev/null +++ b/.github/workflows/dis-cache-release.yml @@ -0,0 +1,96 @@ +name: Scan/Release Dis Redis Operator Image and Build Kustomize OCI Artifact + +env: + FLUX_ARTIFACT_NAME: dis/kustomize/dis-cache-operator + +on: + pull_request: + branches: + - main + paths: + - .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-cache-release.yml + - services/dis-cache-operator/** + - .github/workflows/reusable-image-scan-and-release-ghcr.yml + tags: + - "dis-cache-v*" + workflow_dispatch: {} + +permissions: + contents: read + +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 + image_name: dis-cache-operator + tag_prefix: dis-cache-v + platforms: "linux/amd64" + workdir: ./services/dis-cache-operator + 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-cache-operator/config + runs-on: ubuntu-latest + 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: + workdir: ./services/dis-cache-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-cache-v') + environment: flux-release + permissions: + contents: read + packages: write + id-token: write + defaults: + run: + working-directory: ./services/dis-cache-operator/config + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Setup vars + id: vars + run: | + 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-cache-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/.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/.release-please-manifest.json b/.release-please-manifest.json index eef57effb..d996773b7 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-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/release-please-config.json b/release-please-config.json index 3953ee5c5..efb79de65 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -16,6 +16,10 @@ "release-type": "simple", "component": "dis-vault" }, + "services/dis-cache-operator": { + "release-type": "simple", + "component": "dis-cache" + }, "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..f7299b332 --- /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-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 + +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-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 + +```mermaid +sequenceDiagram +participant dev as App Team +participant kapi as Kubernetes API +participant redisop as dis-cache-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-cache-operator/.trivyignore b/services/dis-cache-operator/.trivyignore new file mode 100644 index 000000000..e69de29bb diff --git a/services/dis-cache-operator/AGENTS.md b/services/dis-cache-operator/AGENTS.md new file mode 100644 index 000000000..5446c42f6 --- /dev/null +++ b/services/dis-cache-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-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`. + +## 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-cache-operator/CHANGELOG.md b/services/dis-cache-operator/CHANGELOG.md new file mode 100644 index 000000000..825c32f0d --- /dev/null +++ b/services/dis-cache-operator/CHANGELOG.md @@ -0,0 +1 @@ +# Changelog diff --git a/services/dis-cache-operator/Dockerfile b/services/dis-cache-operator/Dockerfile new file mode 100644 index 000000000..302b5452c --- /dev/null +++ b/services/dis-cache-operator/Dockerfile @@ -0,0 +1,34 @@ +# Build the manager binary +FROM golang:1.26.3@sha256:6df14f4a4bc9d979a3721f488981e0d1b318006377e473ed23d026796f5f4c0a 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-cache-operator/Makefile b/services/dis-cache-operator/Makefile new file mode 100644 index 000000000..fe26e638c --- /dev/null +++ b/services/dis-cache-operator/Makefile @@ -0,0 +1,369 @@ +# 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-cache-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-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-cache-operator ./services/dis-identity-operator; \ + else \ + go work init ./services/dis-cache-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 '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 + +##@ 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-cache-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: 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) + @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-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-cache-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 )"; \ + 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 )"; \ + 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.4 +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-cache-operator/PROJECT b/services/dis-cache-operator/PROJECT new file mode 100644 index 000000000..7cd198478 --- /dev/null +++ b/services/dis-cache-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-cache-operator +repo: github.com/Altinn/altinn-platform/services/dis-cache-operator +version: "3" diff --git a/services/dis-cache-operator/README.md b/services/dis-cache-operator/README.md new file mode 100644 index 000000000..92f3521f0 --- /dev/null +++ b/services/dis-cache-operator/README.md @@ -0,0 +1,13 @@ +# 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. + +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-cache-operator/api/v1alpha1/groupversion_info.go b/services/dis-cache-operator/api/v1alpha1/groupversion_info.go new file mode 100644 index 000000000..7c5c6c604 --- /dev/null +++ b/services/dis-cache-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-cache-operator/api/v1alpha1/redis_types.go b/services/dis-cache-operator/api/v1alpha1/redis_types.go new file mode 100644 index 000000000..30b6a7b1b --- /dev/null +++ b/services/dis-cache-operator/api/v1alpha1/redis_types.go @@ -0,0 +1,244 @@ +/* +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. +// +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 + // +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: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" +// +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-cache-operator/api/v1alpha1/zz_generated.deepcopy.go b/services/dis-cache-operator/api/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 000000000..7cf62242d --- /dev/null +++ b/services/dis-cache-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 *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 *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 + 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 +} + +// 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-cache-operator/cmd/main.go b/services/dis-cache-operator/cmd/main.go new file mode 100644 index 000000000..3835e06a6 --- /dev/null +++ b/services/dis-cache-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" + + 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" + 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-cache-operator/config/crd/bases/redis.dis.altinn.cloud_redises.yaml b/services/dis-cache-operator/config/crd/bases/redis.dis.altinn.cloud_redises.yaml new file mode 100644 index 000000000..8c4b2fb0c --- /dev/null +++ b/services/dis-cache-operator/config/crd/bases/redis.dis.altinn.cloud_redises.yaml @@ -0,0 +1,281 @@ +--- +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: + - 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 + name: v1alpha1 + schema: + openAPIV3Schema: + description: Redis is the Schema for the redises API. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: Spec defines the desired state of Redis. + properties: + clientProtocol: + default: Encrypted + description: ClientProtocol selects between Encrypted (TLS) and Plaintext. + Defaults to Encrypted. + enum: + - Encrypted + - Plaintext + type: string + evictionPolicy: + default: NoEviction + description: EvictionPolicy selects the database eviction policy. + Defaults to NoEviction. + enum: + - AllKeysLFU + - AllKeysLRU + - AllKeysRandom + - VolatileLFU + - VolatileLRU + - 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: + description: Modules is the optional list of Redis modules enabled + on the database. + items: + description: RedisModule enables a single optional Redis module + on the database. + properties: + args: + description: Args are optional, module-specific arguments. + type: string + name: + description: Name is the module identifier. + enum: + - RedisJSON + - RediSearch + - RedisTimeSeries + - RedisBloom + type: string + required: + - name + type: object + type: array + persistence: + description: Persistence configures optional AOF / RDB persistence. + Defaults to no persistence. + properties: + aof: + description: AOF enables append-only-file persistence with the + specified frequency. + enum: + - Always + - Every1Second + type: string + rdb: + description: RDB enables snapshot persistence with the specified + frequency. + enum: + - 1h + - 6h + - 12h + 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 + 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: + description: Conditions represent the current state of this Redis. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + 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 + observedGeneration: + description: ObservedGeneration is the latest generation reconciled + by the controller. + format: int64 + type: integer + ownerPrincipalId: + description: OwnerPrincipalID is the resolved owner principal ID. + type: string + port: + description: Port is the database client port (defaults to 10000 for + Redis Enterprise). + format: int32 + type: integer + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/services/dis-cache-operator/config/crd/kustomization.yaml b/services/dis-cache-operator/config/crd/kustomization.yaml new file mode 100644 index 000000000..9b350f039 --- /dev/null +++ b/services/dis-cache-operator/config/crd/kustomization.yaml @@ -0,0 +1,2 @@ +resources: +- bases/redis.dis.altinn.cloud_redises.yaml diff --git a/services/dis-cache-operator/config/default/deploy_vars_patch.yaml b/services/dis-cache-operator/config/default/deploy_vars_patch.yaml new file mode 100644 index 000000000..c4f36bdbf --- /dev/null +++ b/services/dis-cache-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-cache-operator/config/default/kustomization.yaml b/services/dis-cache-operator/config/default/kustomization.yaml new file mode 100644 index 000000000..6e8dd8343 --- /dev/null +++ b/services/dis-cache-operator/config/default/kustomization.yaml @@ -0,0 +1,16 @@ +namespace: dis-cache-operator-system +namePrefix: dis-cache-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-cache-operator/config/default/manager_metrics_patch.yaml b/services/dis-cache-operator/config/default/manager_metrics_patch.yaml new file mode 100644 index 000000000..2aaef6536 --- /dev/null +++ b/services/dis-cache-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-cache-operator/config/default/metrics_service.yaml b/services/dis-cache-operator/config/default/metrics_service.yaml new file mode 100644 index 000000000..93d4aa099 --- /dev/null +++ b/services/dis-cache-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-cache-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-cache-operator diff --git a/services/dis-cache-operator/config/kind/applicationidentities.yaml b/services/dis-cache-operator/config/kind/applicationidentities.yaml new file mode 100644 index 000000000..bd4f03ec3 --- /dev/null +++ b/services/dis-cache-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-cache-operator/config/kind/kustomization.yaml b/services/dis-cache-operator/config/kind/kustomization.yaml new file mode 100644 index 000000000..4bd3d0928 --- /dev/null +++ b/services/dis-cache-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-cache-operator-controller-manager + namespace: dis-cache-operator-system diff --git a/services/dis-cache-operator/config/kind/manager_kind_patch.yaml b/services/dis-cache-operator/config/kind/manager_kind_patch.yaml new file mode 100644 index 000000000..3346f3dd6 --- /dev/null +++ b/services/dis-cache-operator/config/kind/manager_kind_patch.yaml @@ -0,0 +1,33 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: dis-cache-operator-controller-manager + namespace: dis-cache-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-cache-operator/config/kind/serviceaccounts.yaml b/services/dis-cache-operator/config/kind/serviceaccounts.yaml new file mode 100644 index 000000000..8dcd23938 --- /dev/null +++ b/services/dis-cache-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-cache-operator/config/manager/kustomization.yaml b/services/dis-cache-operator/config/manager/kustomization.yaml new file mode 100644 index 000000000..5c5f0b84c --- /dev/null +++ b/services/dis-cache-operator/config/manager/kustomization.yaml @@ -0,0 +1,2 @@ +resources: +- manager.yaml diff --git a/services/dis-cache-operator/config/manager/manager.yaml b/services/dis-cache-operator/config/manager/manager.yaml new file mode 100644 index 000000000..3b54fc34c --- /dev/null +++ b/services/dis-cache-operator/config/manager/manager.yaml @@ -0,0 +1,74 @@ +apiVersion: v1 +kind: Namespace +metadata: + labels: + control-plane: controller-manager + app.kubernetes.io/name: dis-cache-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-cache-operator + app.kubernetes.io/managed-by: kustomize +spec: + selector: + matchLabels: + control-plane: controller-manager + app.kubernetes.io/name: dis-cache-operator + replicas: 1 + template: + metadata: + annotations: + kubectl.kubernetes.io/default-container: manager + labels: + control-plane: controller-manager + app.kubernetes.io/name: dis-cache-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-cache-operator/config/network-policy/allow-metrics-traffic.yaml b/services/dis-cache-operator/config/network-policy/allow-metrics-traffic.yaml new file mode 100644 index 000000000..8ad461401 --- /dev/null +++ b/services/dis-cache-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-cache-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-cache-operator + policyTypes: + - Ingress + ingress: + - from: + - namespaceSelector: + matchLabels: + metrics: enabled + ports: + - port: 8443 + protocol: TCP diff --git a/services/dis-cache-operator/config/network-policy/kustomization.yaml b/services/dis-cache-operator/config/network-policy/kustomization.yaml new file mode 100644 index 000000000..ec0fb5e57 --- /dev/null +++ b/services/dis-cache-operator/config/network-policy/kustomization.yaml @@ -0,0 +1,2 @@ +resources: +- allow-metrics-traffic.yaml diff --git a/services/dis-cache-operator/config/prometheus/kustomization.yaml b/services/dis-cache-operator/config/prometheus/kustomization.yaml new file mode 100644 index 000000000..ed137168a --- /dev/null +++ b/services/dis-cache-operator/config/prometheus/kustomization.yaml @@ -0,0 +1,2 @@ +resources: +- monitor.yaml diff --git a/services/dis-cache-operator/config/prometheus/monitor.yaml b/services/dis-cache-operator/config/prometheus/monitor.yaml new file mode 100644 index 000000000..8a6c6fa7e --- /dev/null +++ b/services/dis-cache-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-cache-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-cache-operator diff --git a/services/dis-cache-operator/config/rbac/kustomization.yaml b/services/dis-cache-operator/config/rbac/kustomization.yaml new file mode 100644 index 000000000..af4a11865 --- /dev/null +++ b/services/dis-cache-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-cache-operator/config/rbac/leader_election_role.yaml b/services/dis-cache-operator/config/rbac/leader_election_role.yaml new file mode 100644 index 000000000..628a56a33 --- /dev/null +++ b/services/dis-cache-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-cache-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-cache-operator/config/rbac/leader_election_role_binding.yaml b/services/dis-cache-operator/config/rbac/leader_election_role_binding.yaml new file mode 100644 index 000000000..819a74bd5 --- /dev/null +++ b/services/dis-cache-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-cache-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-cache-operator/config/rbac/metrics_auth_role.yaml b/services/dis-cache-operator/config/rbac/metrics_auth_role.yaml new file mode 100644 index 000000000..32d2e4ec6 --- /dev/null +++ b/services/dis-cache-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-cache-operator/config/rbac/metrics_auth_role_binding.yaml b/services/dis-cache-operator/config/rbac/metrics_auth_role_binding.yaml new file mode 100644 index 000000000..e775d67ff --- /dev/null +++ b/services/dis-cache-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-cache-operator/config/rbac/metrics_reader_role.yaml b/services/dis-cache-operator/config/rbac/metrics_reader_role.yaml new file mode 100644 index 000000000..51a75db47 --- /dev/null +++ b/services/dis-cache-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-cache-operator/config/rbac/role.yaml b/services/dis-cache-operator/config/rbac/role.yaml new file mode 100644 index 000000000..a6ba6878c --- /dev/null +++ b/services/dis-cache-operator/config/rbac/role.yaml @@ -0,0 +1,90 @@ +--- +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: + - privatednszones + - privatednszonesvirtualnetworklinks + - privateendpoints + - privateendpointsprivatednszonegroups + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - network.azure.com + resources: + - privatednszones/status + - privatednszonesvirtualnetworklinks/status + - privateendpoints/status + - privateendpointsprivatednszonegroups/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-cache-operator/config/rbac/role_binding.yaml b/services/dis-cache-operator/config/rbac/role_binding.yaml new file mode 100644 index 000000000..00b054325 --- /dev/null +++ b/services/dis-cache-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-cache-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-cache-operator/config/rbac/service_account.yaml b/services/dis-cache-operator/config/rbac/service_account.yaml new file mode 100644 index 000000000..3cab674b9 --- /dev/null +++ b/services/dis-cache-operator/config/rbac/service_account.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + labels: + app.kubernetes.io/name: dis-cache-operator + app.kubernetes.io/managed-by: kustomize + name: controller-manager + namespace: system diff --git a/services/dis-cache-operator/config/samples/kustomization.yaml b/services/dis-cache-operator/config/samples/kustomization.yaml new file mode 100644 index 000000000..8febc70f5 --- /dev/null +++ b/services/dis-cache-operator/config/samples/kustomization.yaml @@ -0,0 +1,3 @@ +namespace: default +resources: +- redis_v1alpha1_redis.yaml diff --git a/services/dis-cache-operator/config/samples/redis_v1alpha1_redis.yaml b/services/dis-cache-operator/config/samples/redis_v1alpha1_redis.yaml new file mode 100644 index 000000000..5ab017853 --- /dev/null +++ b/services/dis-cache-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-cache-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-cache-operator/config/samples/redis_v1alpha1_service_account_redis.yaml b/services/dis-cache-operator/config/samples/redis_v1alpha1_service_account_redis.yaml new file mode 100644 index 000000000..ce52366f1 --- /dev/null +++ b/services/dis-cache-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-cache-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-cache-operator/go.mod b/services/dis-cache-operator/go.mod new file mode 100644 index 000000000..bc47bd762 --- /dev/null +++ b/services/dis-cache-operator/go.mod @@ -0,0 +1,109 @@ +module github.com/Altinn/altinn-platform/services/dis-cache-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.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 + 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.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 + 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.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.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.42.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.5.0 // 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 + 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-cache-operator/go.sum b/services/dis-cache-operator/go.sum new file mode 100644 index 000000000..f88e41d12 --- /dev/null +++ b/services/dis-cache-operator/go.sum @@ -0,0 +1,381 @@ +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= +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.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= +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.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= +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.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.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.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.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= +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-cache-operator/hack/boilerplate.go.txt b/services/dis-cache-operator/hack/boilerplate.go.txt new file mode 100644 index 000000000..978679816 --- /dev/null +++ b/services/dis-cache-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-cache-operator/internal/config/config.go b/services/dis-cache-operator/internal/config/config.go new file mode 100644 index 000000000..6f1aa6aea --- /dev/null +++ b/services/dis-cache-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-cache-operator/internal/config/config_test.go b/services/dis-cache-operator/internal/config/config_test.go new file mode 100644 index 000000000..2ada0bfe2 --- /dev/null +++ b/services/dis-cache-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-cache-operator/internal/controller/redis_auth_watch.go b/services/dis-cache-operator/internal/controller/redis_auth_watch.go new file mode 100644 index 000000000..b16ab5984 --- /dev/null +++ b/services/dis-cache-operator/internal/controller/redis_auth_watch.go @@ -0,0 +1,52 @@ +package controller + +import ( + "context" + + 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" +) + +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-cache-operator/internal/controller/redis_controller.go b/services/dis-cache-operator/internal/controller/redis_controller.go new file mode 100644 index 000000000..29ba29f88 --- /dev/null +++ b/services/dis-cache-operator/internal/controller/redis_controller.go @@ -0,0 +1,351 @@ +package controller + +import ( + "context" + "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" + 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=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 +// +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 + } + + 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) + 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 || + !peReady.Found || peReady.Status != metav1.ConditionTrue || + !dnsReady.Found || dnsReady.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{}). + Owns(&pev1.PrivateEndpointsPrivateDnsZoneGroup{}). + 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) 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{ + 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 + } + 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 + } + 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 { + 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-cache-operator/internal/controller/redis_controller_network.go b/services/dis-cache-operator/internal/controller/redis_controller_network.go new file mode 100644 index 000000000..7b49e94ab --- /dev/null +++ b/services/dis-cache-operator/internal/controller/redis_controller_network.go @@ -0,0 +1,97 @@ +package controller + +import ( + "context" + "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" + "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-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}) + + 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 { + 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) { + 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 { + 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) { + 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-cache-operator/internal/controller/redis_controller_role.go b/services/dis-cache-operator/internal/controller/redis_controller_role.go new file mode 100644 index 000000000..1c4b60ef3 --- /dev/null +++ b/services/dis-cache-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-cache-operator/internal/controller/redis_controller_status.go b/services/dis-cache-operator/internal/controller/redis_controller_status.go new file mode 100644 index 000000000..0f8865c13 --- /dev/null +++ b/services/dis-cache-operator/internal/controller/redis_controller_status.go @@ -0,0 +1,201 @@ +package controller + +import ( + "context" + "fmt" + + 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" +) + +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(buildDependentCondition( + redisObj.Generation, + redisv1alpha1.ConditionClusterReady, + identity, + clusterReady, + "ClusterNotReady", + "waiting for ASO RedisEnterprise readiness", + )) + databaseCond := applyCondition(buildDependentCondition( + redisObj.Generation, + redisv1alpha1.ConditionDatabaseReady, + identity, + databaseReady, + "DatabaseNotReady", + "waiting for ASO RedisEnterpriseDatabase readiness", + )) + peCond := applyCondition(buildDependentCondition( + redisObj.Generation, + redisv1alpha1.ConditionPrivateEndpointReady, + identity, + privateEndpointReady, + "PrivateEndpointNotReady", + "waiting for ASO PrivateEndpoint readiness", + )) + dnsCond := applyCondition(buildDependentCondition( + redisObj.Generation, + redisv1alpha1.ConditionPrivateDNSReady, + identity, + 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 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, + 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-cache-operator/internal/controller/redis_controller_test.go b/services/dis-cache-operator/internal/controller/redis_controller_test.go new file mode 100644 index 000000000..bc745cc08 --- /dev/null +++ b/services/dis-cache-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-cache-operator/internal/controller/suite_test.go b/services/dis-cache-operator/internal/controller/suite_test.go new file mode 100644 index 000000000..bb6479887 --- /dev/null +++ b/services/dis-cache-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-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 new file mode 100644 index 000000000..93c417f1b --- /dev/null +++ b/services/dis-cache-operator/internal/redis/builders.go @@ -0,0 +1,383 @@ +package redis + +import ( + "fmt" + "maps" + "strings" + + 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" + "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" + + // 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. +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") + } + 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") + } + + location := cfg.Location + connName := PrivateEndpointKubernetesName(r.Name) + + groupID := privateLinkGroupID + 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 +} + +// 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" + 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), + }, + Tags: map[string]string{ + ManagedByTagKey: ManagedByValue, + }, + }, + } +} + +// 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, + }, + }, + Tags: map[string]string{ + ManagedByTagKey: ManagedByValue, + }, + }, + } +} + +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-cache-operator/internal/redis/builders_test.go b/services/dis-cache-operator/internal/redis/builders_test.go new file mode 100644 index 000000000..6c3913b63 --- /dev/null +++ b/services/dis-cache-operator/internal/redis/builders_test.go @@ -0,0 +1,184 @@ +package redis + +import ( + "testing" + + 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" +) + +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 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() + + 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-cache-operator/internal/redis/identity.go b/services/dis-cache-operator/internal/redis/identity.go new file mode 100644 index 000000000..520712c02 --- /dev/null +++ b/services/dis-cache-operator/internal/redis/identity.go @@ -0,0 +1,185 @@ +package redis + +import ( + "context" + "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" + 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") + } + + 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) + 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-cache-operator/internal/redis/identity_test.go b/services/dis-cache-operator/internal/redis/identity_test.go new file mode 100644 index 000000000..6f8fb0dbf --- /dev/null +++ b/services/dis-cache-operator/internal/redis/identity_test.go @@ -0,0 +1,169 @@ +package redis + +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" + 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-cache-operator/internal/redis/labels.go b/services/dis-cache-operator/internal/redis/labels.go new file mode 100644 index 000000000..c66ae44cc --- /dev/null +++ b/services/dis-cache-operator/internal/redis/labels.go @@ -0,0 +1,12 @@ +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-cache-operator" + // ManagedByTagKey is the Azure tag key used on shared resources to identify the managing operator. + ManagedByTagKey = "managed-by" +) diff --git a/services/dis-cache-operator/internal/redis/naming.go b/services/dis-cache-operator/internal/redis/naming.go new file mode 100644 index 000000000..c44695acd --- /dev/null +++ b/services/dis-cache-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-cache-operator/internal/redis/naming_test.go b/services/dis-cache-operator/internal/redis/naming_test.go new file mode 100644 index 000000000..92fcdd481 --- /dev/null +++ b/services/dis-cache-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-cache-operator/internal/redis/status.go b/services/dis-cache-operator/internal/redis/status.go new file mode 100644 index 000000000..a1fa6eb60 --- /dev/null +++ b/services/dis-cache-operator/internal/redis/status.go @@ -0,0 +1,99 @@ +package redis + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + redisv1alpha1 "github.com/Altinn/altinn-platform/services/dis-cache-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 { + if len(conditions) == 0 { + return NewCondition( + redisv1alpha1.ConditionReady, + generation, + metav1.ConditionUnknown, + "NoDependencies", + "no dependency conditions present", + ) + } + + 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-cache-operator/version.txt b/services/dis-cache-operator/version.txt new file mode 100644 index 000000000..6e8bf73aa --- /dev/null +++ b/services/dis-cache-operator/version.txt @@ -0,0 +1 @@ +0.1.0