From 750aa484995c11f82e528230c06b91d21cf88976 Mon Sep 17 00:00:00 2001 From: Morten Zwarenstein Date: Mon, 3 Aug 2026 09:04:12 +0200 Subject: [PATCH 1/6] feat: added safety checks based on availability instead of spec --- internal/controller/util.go | 12 ++++++++++++ internal/controller/volume_controller.go | 4 ++++ 2 files changed, 16 insertions(+) diff --git a/internal/controller/util.go b/internal/controller/util.go index 83efa30..7e56a70 100644 --- a/internal/controller/util.go +++ b/internal/controller/util.go @@ -26,3 +26,15 @@ func hasReplicas(rs appsv1.ReplicaSet) bool { } return *rs.Spec.Replicas > 0 } + +func currentReplicaSetIsAvailable(rsList appsv1.ReplicaSetList, deploymentRevision string) bool { + if deploymentRevision == "" { + return false + } + for _, rs := range rsList.Items { + if rs.Annotations[config.RevisionAnnotation] == deploymentRevision { + return rs.Status.AvailableReplicas > 0 + } + } + return false +} diff --git a/internal/controller/volume_controller.go b/internal/controller/volume_controller.go index 14d482e..e5bbf48 100644 --- a/internal/controller/volume_controller.go +++ b/internal/controller/volume_controller.go @@ -217,6 +217,10 @@ func cleanUpOldReplicaSets(ctx context.Context, c client.Client, obj client.Obje return err } + if !currentReplicaSetIsAvailable(rsList, conf.DeploymentRevision) { + return nil + } + var errs []error for _, rs := range rsList.Items { rsRevision := rs.Annotations[config.RevisionAnnotation] From 7b54470aef8d15d8fec3e527ed31950ad3104859 Mon Sep 17 00:00:00 2001 From: Morten Zwarenstein Date: Mon, 3 Aug 2026 14:20:26 +0200 Subject: [PATCH 2/6] feat: added AGENTS.md and added CLAUDE and copilot instructions symlinks --- .github/copilot-instructions.md | 1 + AGENTS.md | 130 ++++++++++++++++++++++++++++++++ CLAUDE.md | 1 + 3 files changed, 132 insertions(+) create mode 120000 .github/copilot-instructions.md create mode 100644 AGENTS.md create mode 120000 CLAUDE.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 120000 index 0000000..55bf822 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1 @@ +./AGENTS.md \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c8f4130 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,130 @@ +# Volume-operator constitution + +This file is the constitution for this project. It captures persistent principles, conventions, and context. It is personal and not committed to git. + +--- + +## Project overview + +`volume-operator` is a Kubernetes controller (kubebuilder-scaffolded, written in Go) that provisions Azure-backed persistent volumes for Deployments. It is one part of a two-mechanism storage pattern alongside the `ogcapi-operator`. + +--- + +## Full system flow + +### The actors + +| Repo | Path | Role | +|---|---|---| +| `ogcapi-operator` | `~/Workspace/ogcapi-operator` | Manages OGCAPI CRs; creates and owns the Deployment | +| `volume-operator` | this repo | Hooks into ReplicaSet lifecycle; creates the source PVC and AVP | +| `azure-volume-populator` | `~/Workspace/azure-volume-populator` | Watches AVP CRs; runs a populator pod to fill the PVC with blob data | +| `smooth-operator` | `~/Workspace/smooth-operator` | Shared Go utility library used by all of the above | + +### End-to-end sequence + +1. A user creates an **OGCAPI CR** with a `volumeOperatorSpec` (blobPrefix, storagCapacity, storageClass). +2. **ogcapi-operator** reconciles the CR and creates a **Deployment** containing: + - Volume-operator annotations on the Deployment (see Annotations section below) + - An **ephemeral volume** in the pod spec whose `DataSource` points to a PVC named by a hash +3. Kubernetes creates a **ReplicaSet** from the Deployment. +4. **volume-operator** hooks into the ReplicaSet lifecycle. For the active ReplicaSet (revision matches Deployment), it creates: + - An **`AzureVolumePopulator` CR** (the AVP) + - A **source PVC** with `DataSourceRef` pointing to the AVP — both named by the hash +5. **azure-volume-populator** sees the AVP CR and spawns a **populator pod** that copies data from Azure Blob Storage into the source PVC. +6. The source PVC reaches **`Bound`** state. +7. Kubernetes detects that the ephemeral volume's `DataSource` PVC is now Bound and triggers native **volume cloning** — each pod gets its own ephemeral clone. +8. The pod starts with its cloned volume mounted at `/data`. + +### Cleanup on rollout + +When a Deployment rolls out a new ReplicaSet (new revision), on every reconcile of any ReplicaSet in the set: +- volume-operator deletes the **AVP and PVC** (not the ReplicaSet itself — ReplicaSet lifecycle belongs to Kubernetes/the Deployment controller, not this operator) for old ReplicaSets that are scaled to 0 replicas. +- A resource is only deleted if no other ReplicaSet still references the same `resource-suffix` hash with `replicas > 0` (the hash can be shared across revisions when `blobPrefix`/`volumeMountPath`/`storageCapacity` are unchanged — see "Hash-based deduplication" below). +- **Safety gate:** cleanup is skipped entirely unless the ReplicaSet matching the Deployment's *current* revision has `AvailableReplicas > 0` — i.e. the new rollout must actually be serving before old storage is torn down (`currentReplicaSetIsAvailable` in `internal/controller/util.go`). This guards against deleting the old PVC/AVP while the new one is still failing to come up. +- The new and old PVCs are **independent** (different names if the hash changed), so there is no conflict during transition. +- Cleanup logic lives in `cleanUpOldReplicaSets` / `deleteResourcesForReplicaSet` in `internal/controller/volume_controller.go`, backed by `hasReplicas`, `resourceIsUsedByOtherReplicaSet`, and `currentReplicaSetIsAvailable` in `internal/controller/util.go`. + +### Hash-based deduplication + +The resource name (used for both the AVP and PVC) is a hash generated by `ogcapi-operator`: + +``` +hash = GenerateHashFromStrings([]string{blobPrefix, volumeMountPath, storageCapacity}) +``` + +This means: +- If `blobPrefix`, `volumeMountPath`, and `storageCapacity` are unchanged across a new Deployment revision, the hash is identical → volume-operator finds the existing AVP and PVC and skips creation. The same source PVC is reused. +- If any of the three values change (e.g., new blob data), the hash changes → new AVP and PVC are created with the new name, and the old ones are cleaned up after the rollout. + +### Why per ReplicaSet? + +The Deployment itself is stable (managed by ogcapi-operator), but data can change between versions. Tracking ReplicaSet revisions lets volume-operator detect when a new rollout has happened and decide whether new storage resources need to be provisioned. + +--- + +## Annotations + +All annotations use the prefix `volume-operator.pdok.nl` and are read from the **Deployment**: + +| Annotation | Required | Default | Description | +|---|---|---|---| +| `volume-operator.pdok.nl/resource-suffix` | yes | — | The hash used as the name for the AVP and PVC | +| `volume-operator.pdok.nl/blob-prefix` | yes | — | Blob prefix in Azure Blob Storage to populate from | +| `volume-operator.pdok.nl/volume-path` | yes | — | Destination path inside the volume | +| `volume-operator.pdok.nl/storage-capacity` | no | `1Gi` | PVC size | +| `volume-operator.pdok.nl/storage-class` | no | `managed-premium-zrs` | Storage class | + +### Important: naming is externally controlled + +The `resource-suffix` annotation value is a **hash, not a suffix**, and is **not controlled by this operator**. It is generated by `ogcapi-operator` (`addVolumePopulatorToDeployment` in `ogcapi-operator/internal/controller/ogcapi_controller.go`). The name is "suffix" for historical reasons — it was originally a name suffix, but since names can't be specified without hardcoding, it became a standalone hash-derived name. + +--- + +## Technical conventions + +### Language & framework + +- Go (latest stable) +- [Kubebuilder](https://kubebuilder.io) scaffolding — update via `kubebuilder alpha update --from-branch master`, not manual edits to generated files +- `sigs.k8s.io/controller-runtime` for reconciler patterns + +### Testing + +- Test framework: Ginkgo v2 + Gomega +- Preferred: **unit tests with a fake client** (`sigs.k8s.io/controller-runtime/pkg/client/fake`) +- Integration tests use `setup-envtest` (`make test`) +- E2E tests use Kind (`make test-e2e`) +- Flag it when a test doesn't exercise real reconciler logic — the existing `volume_controller_test.go` has `TODO(user)` placeholders and tests no actual behavior + +### Linting + +- `golangci-lint` with extensive rules in `.golangci.yml` +- Run: `make lint` / `make lint-fix` +- Notable rules: max function length 100 lines, max cyclomatic complexity 15, no `fmt.Print*`, no `github.com/pkg/errors` + +### Build & CI + +- `make build` / `docker build` +- `make test` runs unit + integration tests +- GitHub Actions: builds and publishes Docker image on tag push → Docker Hub as `pdok/volume-operator` +- Versioning: semver tags + +### Dependencies + +- Dependencies are normally published versions (not local replace directives) +- `go.work` is occasionally used to pull in sibling repos for local development + +--- + +## Git conventions + +- Do **not** commit or push. +- Reading git history and diffs is fine. +- Staging files is fine. + +--- + +## Keeping this file current + +**Always update `AGENTS.md` after making relevant changes** — annotation renames, new resources created, flow changes, fixed bugs that were documented here, new conventions adopted. This file is the source of truth for future sessions; stale entries cause confusion. \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..55bf822 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +./AGENTS.md \ No newline at end of file From 3ca225240bd2103df6f2330b15c1ad2497b6e3e5 Mon Sep 17 00:00:00 2001 From: Morten Zwarenstein Date: Mon, 3 Aug 2026 14:20:35 +0200 Subject: [PATCH 3/6] test: added unittests --- internal/controller/volume_controller_test.go | 308 +++++++++++++++--- 1 file changed, 255 insertions(+), 53 deletions(-) diff --git a/internal/controller/volume_controller_test.go b/internal/controller/volume_controller_test.go index 081d6b4..7ef4091 100644 --- a/internal/controller/volume_controller_test.go +++ b/internal/controller/volume_controller_test.go @@ -21,9 +21,11 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + + "github.com/PDOK/volume-operator/internal/config" avp "github.com/pdok/azure-volume-populator/api/v1alpha1" - v1 "k8s.io/api/apps/v1" - "k8s.io/apimachinery/pkg/api/errors" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" @@ -32,69 +34,269 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" ) +const ( + testNamespace = "default" + + // Not exported by the config package, mirrored here from the documented + // volume-operator.pdok.nl annotations (see CLAUDE.md). + blobPrefixAnnotation = "volume-operator.pdok.nl/blob-prefix" + volumePathAnnotation = "volume-operator.pdok.nl/volume-path" +) + +func newDeployment(name, revision string, annotations map[string]string) *appsv1.Deployment { + allAnnotations := map[string]string{config.RevisionAnnotation: revision} + for k, v := range annotations { + allAnnotations[k] = v + } + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: testNamespace, + Annotations: allAnnotations, + }, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": name}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": name}, + }, + }, + }, + } +} + +func newReplicaSet(name string, owner *appsv1.Deployment, revision string, replicas, availableReplicas int32, resourceSuffix string) *appsv1.ReplicaSet { + labels := map[string]string{} + var ownerRefs []metav1.OwnerReference + if owner != nil { + for k, v := range owner.Spec.Selector.MatchLabels { + labels[k] = v + } + ownerRefs = []metav1.OwnerReference{ + { + APIVersion: "apps/v1", + Kind: "Deployment", + Name: owner.Name, + UID: owner.UID, + }, + } + } + + return &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: testNamespace, + Labels: labels, + OwnerReferences: ownerRefs, + Annotations: map[string]string{ + config.RevisionAnnotation: revision, + config.ResourceSuffixAnnotation: resourceSuffix, + }, + }, + Spec: appsv1.ReplicaSetSpec{ + Replicas: &replicas, + Selector: &metav1.LabelSelector{MatchLabels: labels}, + }, + Status: appsv1.ReplicaSetStatus{ + AvailableReplicas: availableReplicas, + }, + } +} + var _ = Describe("Volume Controller", func() { var ( - ctx context.Context - scheme *runtime.Scheme - client client.Client + ctx context.Context + k8sClient client.Client + controllerReconciler *VolumeReconciler ) - Context("When reconciling a resource", func() { - const resourceName = "test-resource" + BeforeEach(func() { ctx = context.Background() - typeNamespacedName := types.NamespacedName{ - Name: resourceName, - Namespace: "default", // TODO(user):Modify as needed - } + scheme := runtime.NewScheme() + Expect(appsv1.AddToScheme(scheme)).To(Succeed()) + Expect(corev1.AddToScheme(scheme)).To(Succeed()) + Expect(avp.AddToScheme(scheme)).To(Succeed()) + + k8sClient = fake.NewClientBuilder().WithScheme(scheme).Build() + controllerReconciler = &VolumeReconciler{Client: k8sClient, Scheme: scheme} + }) + + reconcileRS := func(name string) (reconcile.Result, error) { + return controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: name, Namespace: testNamespace}, + }) + } + + It("does nothing when the ReplicaSet is not found", func() { + _, err := reconcileRS("missing-rs") + Expect(err).NotTo(HaveOccurred()) + }) + + It("skips reconciliation when the ReplicaSet has no owning Deployment", func() { + rs := newReplicaSet("rs-no-owner", nil, "1", 1, 1, "suffix-no-owner") + Expect(k8sClient.Create(ctx, rs)).To(Succeed()) + + _, err := reconcileRS(rs.Name) + Expect(err).NotTo(HaveOccurred()) + + err = k8sClient.Get(ctx, types.NamespacedName{Name: "suffix-no-owner", Namespace: testNamespace}, &avp.AzureVolumePopulator{}) + Expect(err).To(HaveOccurred()) + }) + + It("skips reconciliation when the Deployment is missing the resource-suffix annotation", func() { + deployment := newDeployment("dep-no-suffix", "1", nil) + Expect(k8sClient.Create(ctx, deployment)).To(Succeed()) + + rs := newReplicaSet("rs-no-suffix", deployment, "1", 1, 1, "") + Expect(k8sClient.Create(ctx, rs)).To(Succeed()) + + _, err := reconcileRS(rs.Name) + Expect(err).NotTo(HaveOccurred()) + }) + + It("skips creating resources when the ReplicaSet and Deployment revisions differ", func() { + deployment := newDeployment("dep-revision-mismatch", "2", map[string]string{ + config.ResourceSuffixAnnotation: "suffix-revision-mismatch", + blobPrefixAnnotation: "blob/prefix", + volumePathAnnotation: "/data", + }) + Expect(k8sClient.Create(ctx, deployment)).To(Succeed()) + + rs := newReplicaSet("rs-revision-mismatch", deployment, "1", 1, 1, "suffix-revision-mismatch") + Expect(k8sClient.Create(ctx, rs)).To(Succeed()) + + _, err := reconcileRS(rs.Name) + Expect(err).NotTo(HaveOccurred()) + + err = k8sClient.Get(ctx, types.NamespacedName{Name: "suffix-revision-mismatch", Namespace: testNamespace}, &avp.AzureVolumePopulator{}) + Expect(err).To(HaveOccurred()) + }) - volumepopulator := &avp.AzureVolumePopulator{} - - BeforeEach(func() { - By("creating the custom resource for the Kind VolumePopulator") - scheme = runtime.NewScheme() - _ = v1.AddToScheme(scheme) - _ = avp.AddToScheme(scheme) - client = fake.NewClientBuilder(). - WithScheme(scheme). - Build() - - err := client.Get(ctx, typeNamespacedName, volumepopulator) - if err != nil && errors.IsNotFound(err) { - resource := &avp.AzureVolumePopulator{ - ObjectMeta: metav1.ObjectMeta{ - Name: resourceName, - Namespace: "default", - }, - // TODO(user): Specify other spec details if needed. - } - Expect(client.Create(ctx, resource)).To(Succeed()) - } + It("skips creating resources when required volume annotations are missing", func() { + deployment := newDeployment("dep-missing-vol-annotations", "1", map[string]string{ + config.ResourceSuffixAnnotation: "suffix-missing-vol", }) + Expect(k8sClient.Create(ctx, deployment)).To(Succeed()) - AfterEach(func() { - // TODO(user): Cleanup logic after each test, like removing the resource instance. - resource := &avp.AzureVolumePopulator{} - err := client.Get(ctx, typeNamespacedName, resource) - Expect(err).NotTo(HaveOccurred()) + rs := newReplicaSet("rs-missing-vol", deployment, "1", 1, 1, "suffix-missing-vol") + Expect(k8sClient.Create(ctx, rs)).To(Succeed()) - By("Cleanup the specific resource instance VolumePopulator") - Expect(client.Delete(ctx, resource)).To(Succeed()) + _, err := reconcileRS(rs.Name) + Expect(err).NotTo(HaveOccurred()) + + err = k8sClient.Get(ctx, types.NamespacedName{Name: "suffix-missing-vol", Namespace: testNamespace}, &avp.AzureVolumePopulator{}) + Expect(err).To(HaveOccurred()) + }) + + It("creates the AVP and PVC when all required annotations are present and revisions match", func() { + deployment := newDeployment("dep-happy", "1", map[string]string{ + config.ResourceSuffixAnnotation: "suffix-happy", + blobPrefixAnnotation: "blob/prefix", + volumePathAnnotation: "/data", + }) + Expect(k8sClient.Create(ctx, deployment)).To(Succeed()) + + rs := newReplicaSet("rs-happy", deployment, "1", 1, 1, "suffix-happy") + Expect(k8sClient.Create(ctx, rs)).To(Succeed()) + + _, err := reconcileRS(rs.Name) + Expect(err).NotTo(HaveOccurred()) + + createdAvp := &avp.AzureVolumePopulator{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "suffix-happy", Namespace: testNamespace}, createdAvp)).To(Succeed()) + Expect(createdAvp.Spec.BlobPrefix).To(Equal("blob/prefix")) + Expect(createdAvp.Spec.VolumePath).To(Equal("/data")) + + createdPvc := &corev1.PersistentVolumeClaim{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "suffix-happy", Namespace: testNamespace}, createdPvc)).To(Succeed()) + Expect(createdPvc.Spec.DataSourceRef).NotTo(BeNil()) + Expect(createdPvc.Spec.DataSourceRef.Name).To(Equal(createdAvp.Name)) + }) + + It("does not error or duplicate resources when the AVP and PVC already exist", func() { + deployment := newDeployment("dep-idempotent", "1", map[string]string{ + config.ResourceSuffixAnnotation: "suffix-idempotent", + blobPrefixAnnotation: "blob/prefix", + volumePathAnnotation: "/data", + }) + Expect(k8sClient.Create(ctx, deployment)).To(Succeed()) + + rs := newReplicaSet("rs-idempotent", deployment, "1", 1, 1, "suffix-idempotent") + Expect(k8sClient.Create(ctx, rs)).To(Succeed()) + + _, err := reconcileRS(rs.Name) + Expect(err).NotTo(HaveOccurred()) + + _, err = reconcileRS(rs.Name) + Expect(err).NotTo(HaveOccurred()) + + avpList := &avp.AzureVolumePopulatorList{} + Expect(k8sClient.List(ctx, avpList, client.InNamespace(testNamespace))).To(Succeed()) + Expect(avpList.Items).To(HaveLen(1)) + + pvcList := &corev1.PersistentVolumeClaimList{} + Expect(k8sClient.List(ctx, pvcList, client.InNamespace(testNamespace))).To(Succeed()) + Expect(pvcList.Items).To(HaveLen(1)) + }) + + It("cleans up an old ReplicaSet's AVP and PVC once the new ReplicaSet is available", func() { + deployment := newDeployment("dep-cleanup", "2", map[string]string{ + config.ResourceSuffixAnnotation: "suffix-new", + blobPrefixAnnotation: "blob/prefix", + volumePathAnnotation: "/data", }) + Expect(k8sClient.Create(ctx, deployment)).To(Succeed()) + + oldRs := newReplicaSet("rs-old", deployment, "1", 0, 0, "suffix-old") + Expect(k8sClient.Create(ctx, oldRs)).To(Succeed()) + + newRs := newReplicaSet("rs-new", deployment, "2", 1, 1, "suffix-new") + Expect(k8sClient.Create(ctx, newRs)).To(Succeed()) + + oldAvp := &avp.AzureVolumePopulator{ObjectMeta: metav1.ObjectMeta{Name: "suffix-old", Namespace: testNamespace}} + Expect(k8sClient.Create(ctx, oldAvp)).To(Succeed()) + oldPvc := &corev1.PersistentVolumeClaim{ObjectMeta: metav1.ObjectMeta{Name: "suffix-old", Namespace: testNamespace}} + Expect(k8sClient.Create(ctx, oldPvc)).To(Succeed()) + + _, err := reconcileRS(newRs.Name) + Expect(err).NotTo(HaveOccurred()) + + err = k8sClient.Get(ctx, types.NamespacedName{Name: "suffix-old", Namespace: testNamespace}, &avp.AzureVolumePopulator{}) + Expect(err).To(HaveOccurred()) + err = k8sClient.Get(ctx, types.NamespacedName{Name: "suffix-old", Namespace: testNamespace}, &corev1.PersistentVolumeClaim{}) + Expect(err).To(HaveOccurred()) - It("should successfully reconcile the resource", func() { - By("Reconciling the created resource") - controllerReconciler := &VolumeReconciler{ - Client: client, - Scheme: scheme, - } - - _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ - NamespacedName: typeNamespacedName, - }) - Expect(err).NotTo(HaveOccurred()) - // TODO(user): Add more specific assertions depending on your controller's reconciliation logic. - // Example: If you expect a certain status condition after reconciliation, verify it here. + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "suffix-new", Namespace: testNamespace}, &avp.AzureVolumePopulator{})).To(Succeed()) + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "suffix-new", Namespace: testNamespace}, &corev1.PersistentVolumeClaim{})).To(Succeed()) + }) + + It("does not delete the old ReplicaSet's resources when the new ReplicaSet is not yet available", func() { + deployment := newDeployment("dep-deferred", "2", map[string]string{ + config.ResourceSuffixAnnotation: "suffix-new-deferred", + blobPrefixAnnotation: "blob/prefix", + volumePathAnnotation: "/data", }) + Expect(k8sClient.Create(ctx, deployment)).To(Succeed()) + + oldRs := newReplicaSet("rs-old-deferred", deployment, "1", 0, 0, "suffix-old-deferred") + Expect(k8sClient.Create(ctx, oldRs)).To(Succeed()) + + // New ReplicaSet matches the current Deployment revision but isn't up yet. + newRs := newReplicaSet("rs-new-deferred", deployment, "2", 1, 0, "suffix-new-deferred") + Expect(k8sClient.Create(ctx, newRs)).To(Succeed()) + + oldAvp := &avp.AzureVolumePopulator{ObjectMeta: metav1.ObjectMeta{Name: "suffix-old-deferred", Namespace: testNamespace}} + Expect(k8sClient.Create(ctx, oldAvp)).To(Succeed()) + oldPvc := &corev1.PersistentVolumeClaim{ObjectMeta: metav1.ObjectMeta{Name: "suffix-old-deferred", Namespace: testNamespace}} + Expect(k8sClient.Create(ctx, oldPvc)).To(Succeed()) + + _, err := reconcileRS(newRs.Name) + Expect(err).NotTo(HaveOccurred()) + + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "suffix-old-deferred", Namespace: testNamespace}, &avp.AzureVolumePopulator{})).To(Succeed()) + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "suffix-old-deferred", Namespace: testNamespace}, &corev1.PersistentVolumeClaim{})).To(Succeed()) }) }) From bf16683859f44dd953fd58f5caf9fffe1d236e13 Mon Sep 17 00:00:00 2001 From: Morten Zwarenstein Date: Mon, 3 Aug 2026 14:27:48 +0200 Subject: [PATCH 4/6] fix: better symlink --- .github/copilot-instructions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 55bf822..be77ac8 120000 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1 +1 @@ -./AGENTS.md \ No newline at end of file +../AGENTS.md \ No newline at end of file From 5bc90392685c62546225118f217a234332415e56 Mon Sep 17 00:00:00 2001 From: Morten Zwarenstein Date: Mon, 3 Aug 2026 14:31:30 +0200 Subject: [PATCH 5/6] chore: cleanup non-needed comments --- internal/controller/volume_controller_test.go | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/internal/controller/volume_controller_test.go b/internal/controller/volume_controller_test.go index 7ef4091..fb80a56 100644 --- a/internal/controller/volume_controller_test.go +++ b/internal/controller/volume_controller_test.go @@ -18,6 +18,7 @@ package controller import ( "context" + "maps" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -36,18 +37,13 @@ import ( const ( testNamespace = "default" - - // Not exported by the config package, mirrored here from the documented - // volume-operator.pdok.nl annotations (see CLAUDE.md). blobPrefixAnnotation = "volume-operator.pdok.nl/blob-prefix" volumePathAnnotation = "volume-operator.pdok.nl/volume-path" ) func newDeployment(name, revision string, annotations map[string]string) *appsv1.Deployment { allAnnotations := map[string]string{config.RevisionAnnotation: revision} - for k, v := range annotations { - allAnnotations[k] = v - } + maps.Copy(allAnnotations, annotations) return &appsv1.Deployment{ ObjectMeta: metav1.ObjectMeta{ Name: name, @@ -71,9 +67,7 @@ func newReplicaSet(name string, owner *appsv1.Deployment, revision string, repli labels := map[string]string{} var ownerRefs []metav1.OwnerReference if owner != nil { - for k, v := range owner.Spec.Selector.MatchLabels { - labels[k] = v - } + maps.Copy(labels, owner.Spec.Selector.MatchLabels) ownerRefs = []metav1.OwnerReference{ { APIVersion: "apps/v1", From 7b4d1b4fffe79cfb410332606994eef97da4989f Mon Sep 17 00:00:00 2001 From: Morten Zwarenstein Date: Tue, 4 Aug 2026 09:11:21 +0200 Subject: [PATCH 6/6] chore: ran gofmt --- internal/controller/volume_controller_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/controller/volume_controller_test.go b/internal/controller/volume_controller_test.go index fb80a56..b5371f5 100644 --- a/internal/controller/volume_controller_test.go +++ b/internal/controller/volume_controller_test.go @@ -36,7 +36,7 @@ import ( ) const ( - testNamespace = "default" + testNamespace = "default" blobPrefixAnnotation = "volume-operator.pdok.nl/blob-prefix" volumePathAnnotation = "volume-operator.pdok.nl/volume-path" )