diff --git a/Makefile b/Makefile index dbbf22b02b..1781d7d3bd 100644 --- a/Makefile +++ b/Makefile @@ -64,11 +64,13 @@ deps: # # So we only use the commit id as the version for the binaries built from master branch, # and use the tag as the version for any release branches. +ifeq ($(VERSION),) ifeq ($(shell git rev-parse --abbrev-ref HEAD), master) VERSION := $(shell git log -1 --format=%h) else VERSION := $(shell git describe --dirty --always 2>/dev/null) endif +endif .PHONY: version version: diff --git a/go.mod b/go.mod index 6641830365..4b4415da6f 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/container-storage-interface/spec v1.12.0 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc github.com/evanphx/json-patch/v5 v5.9.11 - github.com/fsnotify/fsnotify v1.10.1 + github.com/fsnotify/fsnotify v1.9.0 github.com/go-co-op/gocron v1.37.0 github.com/go-logr/zapr v1.3.0 github.com/golang/protobuf v1.5.4 @@ -20,28 +20,28 @@ require ( github.com/kubernetes-csi/external-snapshot-metadata/client v1.0.0 github.com/kubernetes-csi/external-snapshotter/client/v8 v8.4.0 github.com/onsi/ginkgo/v2 v2.28.3 - github.com/onsi/gomega v1.41.0 + github.com/onsi/gomega v1.40.0 github.com/prometheus/client_golang v1.23.2 github.com/stretchr/testify v1.11.1 github.com/vmware-tanzu/vm-operator/api v1.9.1-0.20260423003402-51227659e236 - github.com/vmware-tanzu/vm-operator/external/byok v0.0.0-20260528162537-04308ad7298e - github.com/vmware/govmomi v0.54.0 - go.uber.org/zap v1.28.0 - golang.org/x/sync v0.20.0 - golang.org/x/sys v0.45.0 - google.golang.org/grpc v1.81.1 + github.com/vmware-tanzu/vm-operator/external/byok v0.0.0-20260423003402-51227659e236 + github.com/vmware/govmomi v0.53.0-alpha.0.0.20260418033738-262be50854f2 + go.uber.org/zap v1.27.1 + golang.org/x/sync v0.21.0 + golang.org/x/sys v0.44.0 + google.golang.org/grpc v1.80.0 google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af gopkg.in/gcfg.v1 v1.2.3 - k8s.io/api v0.36.1 - k8s.io/apiextensions-apiserver v0.36.1 - k8s.io/apimachinery v0.36.1 - k8s.io/client-go v0.36.1 - k8s.io/kubernetes v1.36.1 - k8s.io/mount-utils v0.36.1 - k8s.io/sample-controller v0.36.1 - k8s.io/utils v0.0.0-20260507154919-ff6756f316d2 - sigs.k8s.io/cluster-api v1.13.2 - sigs.k8s.io/controller-runtime v0.24.1 + k8s.io/api v0.36.0 + k8s.io/apiextensions-apiserver v0.36.0 + k8s.io/apimachinery v0.36.0 + k8s.io/client-go v0.36.0 + k8s.io/kubernetes v1.36.0 + k8s.io/mount-utils v0.36.0 + k8s.io/sample-controller v0.36.0 + k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 + sigs.k8s.io/cluster-api v1.13.1 + sigs.k8s.io/controller-runtime v0.24.0 ) require ( @@ -52,33 +52,33 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cyphar/filepath-securejoin v0.6.1 // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect - github.com/fxamacker/cbor/v2 v2.9.2 // indirect + github.com/fxamacker/cbor/v2 v2.9.1 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-ole/go-ole v1.3.0 // indirect - github.com/go-openapi/jsonpointer v0.23.1 // indirect + github.com/go-openapi/jsonpointer v0.22.5 // indirect github.com/go-openapi/jsonreference v0.21.5 // indirect - github.com/go-openapi/swag v0.26.0 // indirect - github.com/go-openapi/swag/cmdutils v0.26.0 // indirect - github.com/go-openapi/swag/conv v0.26.0 // indirect - github.com/go-openapi/swag/fileutils v0.26.0 // indirect - github.com/go-openapi/swag/jsonname v0.26.0 // indirect - github.com/go-openapi/swag/jsonutils v0.26.0 // indirect - github.com/go-openapi/swag/loading v0.26.0 // indirect - github.com/go-openapi/swag/mangling v0.26.0 // indirect - github.com/go-openapi/swag/netutils v0.26.0 // indirect - github.com/go-openapi/swag/stringutils v0.26.0 // indirect - github.com/go-openapi/swag/typeutils v0.26.0 // indirect - github.com/go-openapi/swag/yamlutils v0.26.0 // indirect + github.com/go-openapi/swag v0.25.5 // indirect + github.com/go-openapi/swag/cmdutils v0.25.5 // indirect + github.com/go-openapi/swag/conv v0.25.5 // indirect + github.com/go-openapi/swag/fileutils v0.25.5 // indirect + github.com/go-openapi/swag/jsonname v0.25.5 // indirect + github.com/go-openapi/swag/jsonutils v0.25.5 // indirect + github.com/go-openapi/swag/loading v0.25.5 // indirect + github.com/go-openapi/swag/mangling v0.25.5 // indirect + github.com/go-openapi/swag/netutils v0.25.5 // indirect + github.com/go-openapi/swag/stringutils v0.25.5 // indirect + github.com/go-openapi/swag/typeutils v0.25.5 // indirect + github.com/go-openapi/swag/yamlutils v0.25.5 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/google/gnostic-models v0.7.1 // indirect github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/microsoft/wmi v0.42.0 // indirect + github.com/microsoft/wmi v0.39.0 // indirect github.com/moby/sys/mountinfo v0.7.2 // 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/opencontainers/selinux v1.15.0 // indirect + github.com/opencontainers/selinux v1.13.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.2 // indirect @@ -90,32 +90,32 @@ require ( github.com/stretchr/objx v0.5.2 // indirect github.com/thecodeteam/gofsutil v0.1.2 // indirect github.com/x448/float16 v0.8.4 // indirect - go.opentelemetry.io/otel v1.44.0 // indirect - go.opentelemetry.io/otel/trace v1.44.0 // indirect - go.uber.org/atomic v1.11.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/mod v0.35.0 // indirect - golang.org/x/net v0.55.0 // indirect + golang.org/x/mod v0.36.0 // indirect + golang.org/x/net v0.54.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/term v0.43.0 // indirect - golang.org/x/text v0.37.0 // indirect + golang.org/x/text v0.38.0 // indirect golang.org/x/time v0.15.0 // indirect - golang.org/x/tools v0.44.0 // indirect + golang.org/x/tools v0.45.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/apiserver v0.36.1 // indirect - k8s.io/component-base v0.36.1 // indirect - k8s.io/controller-manager v0.36.1 // indirect + k8s.io/apiserver v0.36.0 // indirect + k8s.io/component-base v0.36.0 // indirect + k8s.io/controller-manager v0.36.0 // indirect k8s.io/csi-translation-lib v0.36.0 // indirect k8s.io/klog/v2 v2.140.0 // indirect - k8s.io/kube-openapi v0.0.0-20260520065146-aa012df4f4af // indirect - sigs.k8s.io/gateway-api v1.5.1 // indirect + k8s.io/kube-openapi v0.0.0-20260414162039-ec9c827d403f // indirect + sigs.k8s.io/gateway-api v1.5.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.4.0 // indirect @@ -126,12 +126,14 @@ require ( // replace directives. Consumers must supply their own replaces to real // published versions. replace ( + // TODO: drop replace once CNS unregister APIs are merged into a tagged upstream govmomi release. + github.com/vmware/govmomi => github.com/deepakkinni/govmomi v0.24.1-0.20260622060325-1101ef518042 k8s.io/api => k8s.io/api v0.36.0 k8s.io/apiextensions-apiserver => k8s.io/apiextensions-apiserver v0.36.0 k8s.io/apimachinery => k8s.io/apimachinery v0.36.0 k8s.io/apiserver => k8s.io/apiserver v0.36.0 k8s.io/cli-runtime => k8s.io/cli-runtime v0.36.0 - k8s.io/client-go => k8s.io/client-go v0.36.1 + k8s.io/client-go => k8s.io/client-go v0.36.0 k8s.io/cloud-provider => k8s.io/cloud-provider v0.36.0 k8s.io/cluster-bootstrap => k8s.io/cluster-bootstrap v0.36.0 k8s.io/code-generator => k8s.io/code-generator v0.36.0 diff --git a/go.sum b/go.sum index bdf274621f..581edaa24b 100644 --- a/go.sum +++ b/go.sum @@ -23,6 +23,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs 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/deepakkinni/govmomi v0.24.1-0.20260622060325-1101ef518042 h1:c7JssL0Uufgsr8hoPgbzLwI0RNx9SrZCp3/YLHCePCE= +github.com/deepakkinni/govmomi v0.24.1-0.20260622060325-1101ef518042/go.mod h1:QR6UoTHdmvT5XvdomNKwyi7VPOnrE0QZxjPBJ0mWWQs= github.com/dougm/pretty v0.0.0-20160325215624-add1dbc86daf h1:A2XbJkAuMMFy/9EftoubSKBUIyiOm6Z8+X5G7QpS6so= github.com/dougm/pretty v0.0.0-20160325215624-add1dbc86daf/go.mod h1:7NQ3kWOx2cZOSjtcveTa5nqupVr2s6/83sG+rTlI7uA= github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= @@ -31,10 +33,10 @@ github.com/evanphx/json-patch v5.9.0+incompatible h1:fBXyNpNMuTTDdquAq/uisOr2lSh 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/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= -github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= -github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= -github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +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.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ= +github.com/fxamacker/cbor/v2 v2.9.1/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= @@ -51,40 +53,40 @@ 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-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= -github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= -github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= +github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA= +github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0= github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE= github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw= -github.com/go-openapi/swag v0.26.0 h1:GVDXCmfvhfu1BxiHo8/FA+BbKmhecHnG3varjON5/RI= -github.com/go-openapi/swag v0.26.0/go.mod h1:82g3193sZJRbocs7bNCqGfIgq8pkuwVwCfhKIRlEQF0= -github.com/go-openapi/swag/cmdutils v0.26.0 h1:iowihOcvq7y4egO8cOq0dmfohz6wfeQ63U1EnuhO2TU= -github.com/go-openapi/swag/cmdutils v0.26.0/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= -github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I= -github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE= -github.com/go-openapi/swag/fileutils v0.26.0 h1:WJoPRvsA7QRiiWluowkLJa9jaYR7FCuxmDvnCgaRRxU= -github.com/go-openapi/swag/fileutils v0.26.0/go.mod h1:0WDJ7lp67eNjPMO50wAWYlKvhOb6CQ37rzR7wrgI8Tc= -github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w= -github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M= -github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA= -github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0 h1:apqeINu/ICHouqiRZbyFvuDge5jCmmLTqGQ9V95EaOM= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0/go.mod h1:AyM6QT8uz5IdKxk5akv0y6u4QvcL9GWERt0Jx/F/R8Y= -github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaMX7kilDcko= -github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg= -github.com/go-openapi/swag/mangling v0.26.0 h1:Du2YC4YLA/Y5m/YKQd7AnY5qq0wRKSFZTTt8ktFaXcQ= -github.com/go-openapi/swag/mangling v0.26.0/go.mod h1:jifS7W9vbg+pw63bT+GI53otluMQL3CeemuyCHKwVx0= -github.com/go-openapi/swag/netutils v0.26.0 h1:CmZp+ZT7HrmFwrC3GdGsXBq2+42T1bjKBapcqVpIs3c= -github.com/go-openapi/swag/netutils v0.26.0/go.mod h1:5iK+Ok3ZohWWex1C50BFTPexi03UaPwjW4Oj8kgrpwo= -github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg= -github.com/go-openapi/swag/stringutils v0.26.0/go.mod h1:sWn5uY+QIIspwPhvgnqJsH8xqFT2ZbYcvbcFanRyhFE= -github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFuuyQIKYybfq4= -github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE= -github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ= -github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU= -github.com/go-openapi/testify/enable/yaml/v2 v2.4.2 h1:5zRca5jw7lzVREKCZVNBpysDNBjj74rBh0N2BGQbSR0= -github.com/go-openapi/testify/enable/yaml/v2 v2.4.2/go.mod h1:XVevPw5hUXuV+5AkI1u1PeAm27EQVrhXTTCPAF85LmE= -github.com/go-openapi/testify/v2 v2.4.2 h1:tiByHpvE9uHrrKjOszax7ZvKB7QOgizBWGBLuq0ePx4= -github.com/go-openapi/testify/v2 v2.4.2/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/go-openapi/swag v0.25.5 h1:pNkwbUEeGwMtcgxDr+2GBPAk4kT+kJ+AaB+TMKAg+TU= +github.com/go-openapi/swag v0.25.5/go.mod h1:B3RT6l8q7X803JRxa2e59tHOiZlX1t8viplOcs9CwTA= +github.com/go-openapi/swag/cmdutils v0.25.5 h1:yh5hHrpgsw4NwM9KAEtaDTXILYzdXh/I8Whhx9hKj7c= +github.com/go-openapi/swag/cmdutils v0.25.5/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= +github.com/go-openapi/swag/conv v0.25.5 h1:wAXBYEXJjoKwE5+vc9YHhpQOFj2JYBMF2DUi+tGu97g= +github.com/go-openapi/swag/conv v0.25.5/go.mod h1:CuJ1eWvh1c4ORKx7unQnFGyvBbNlRKbnRyAvDvzWA4k= +github.com/go-openapi/swag/fileutils v0.25.5 h1:B6JTdOcs2c0dBIs9HnkyTW+5gC+8NIhVBUwERkFhMWk= +github.com/go-openapi/swag/fileutils v0.25.5/go.mod h1:V3cT9UdMQIaH4WiTrUc9EPtVA4txS0TOmRURmhGF4kc= +github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo= +github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU= +github.com/go-openapi/swag/jsonutils v0.25.5 h1:XUZF8awQr75MXeC+/iaw5usY/iM7nXPDwdG3Jbl9vYo= +github.com/go-openapi/swag/jsonutils v0.25.5/go.mod h1:48FXUaz8YsDAA9s5AnaUvAmry1UcLcNVWUjY42XkrN4= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5 h1:SX6sE4FrGb4sEnnxbFL/25yZBb5Hcg1inLeErd86Y1U= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5/go.mod h1:/2KvOTrKWjVA5Xli3DZWdMCZDzz3uV/T7bXwrKWPquo= +github.com/go-openapi/swag/loading v0.25.5 h1:odQ/umlIZ1ZVRteI6ckSrvP6e2w9UTF5qgNdemJHjuU= +github.com/go-openapi/swag/loading v0.25.5/go.mod h1:I8A8RaaQ4DApxhPSWLNYWh9NvmX2YKMoB9nwvv6oW6g= +github.com/go-openapi/swag/mangling v0.25.5 h1:hyrnvbQRS7vKePQPHHDso+k6CGn5ZBs5232UqWZmJZw= +github.com/go-openapi/swag/mangling v0.25.5/go.mod h1:6hadXM/o312N/h98RwByLg088U61TPGiltQn71Iw0NY= +github.com/go-openapi/swag/netutils v0.25.5 h1:LZq2Xc2QI8+7838elRAaPCeqJnHODfSyOa7ZGfxDKlU= +github.com/go-openapi/swag/netutils v0.25.5/go.mod h1:lHbtmj4m57APG/8H7ZcMMSWzNqIQcu0RFiXrPUara14= +github.com/go-openapi/swag/stringutils v0.25.5 h1:NVkoDOA8YBgtAR/zvCx5rhJKtZF3IzXcDdwOsYzrB6M= +github.com/go-openapi/swag/stringutils v0.25.5/go.mod h1:PKK8EZdu4QJq8iezt17HM8RXnLAzY7gW0O1KKarrZII= +github.com/go-openapi/swag/typeutils v0.25.5 h1:EFJ+PCga2HfHGdo8s8VJXEVbeXRCYwzzr9u4rJk7L7E= +github.com/go-openapi/swag/typeutils v0.25.5/go.mod h1:itmFmScAYE1bSD8C4rS0W+0InZUBrB2xSPbWt6DLGuc= +github.com/go-openapi/swag/yamlutils v0.25.5 h1:kASCIS+oIeoc55j28T4o8KwlV2S4ZLPT6G0iq2SSbVQ= +github.com/go-openapi/swag/yamlutils v0.25.5/go.mod h1:Gek1/SjjfbYvM+Iq4QGwa/2lEXde9n2j4a3wI3pNuOQ= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.0 h1:7SgOMTvJkM8yWrQlU8Jm18VeDPuAvB/xWrdxFJkoFag= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.0/go.mod h1:14iV8jyyQlinc9StD7w1xVPW3CO3q1Gj04Jy//Kw4VM= +github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM= +github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= 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= @@ -134,8 +136,8 @@ 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/wmi v0.42.0 h1:Uma0gKtJMaau9LXpfYNeu9i7t1t6bIAHPoxfKLSvQdQ= -github.com/microsoft/wmi v0.42.0/go.mod h1:pEPSA8nFWrDOc4SSFRZfZaurx+9HT58ZWDo8vdVWIrI= +github.com/microsoft/wmi v0.39.0 h1:bMrQPNgRjqt0nqtB7n+L9YL+cIv9cqUUwvG1Ng868OA= +github.com/microsoft/wmi v0.39.0/go.mod h1:6HLb2MXkTUtLuLAagNpPm0hkKqhiqnUYYqEHkfIasTI= github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg= github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -148,10 +150,10 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/onsi/ginkgo/v2 v2.28.3 h1:4JvMdwtFU0imd8fHx25OJXoDMRexnf8v5NHKYSTTji4= github.com/onsi/ginkgo/v2 v2.28.3/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= -github.com/onsi/gomega v1.41.0 h1:OwKp4pXNgVxf6sCplzYo794OFNuoL2q2SBMU5NSWOjA= -github.com/onsi/gomega v1.41.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A= -github.com/opencontainers/selinux v1.15.0 h1:4Gs40e/R2FvM8PC1HPaPncLLaDor8Y2WDfk5gjU9o5M= -github.com/opencontainers/selinux v1.15.0/go.mod h1:LenyElirjUHszfxrjuFqC85HIeXZKumHcKMQtnaDlQQ= +github.com/onsi/gomega v1.40.0 h1:Vtol0e1MghCD2ZVIilPDIg44XSL9l2QAn8ZNaljWcJc= +github.com/onsi/gomega v1.40.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A= +github.com/opencontainers/selinux v1.13.1 h1:A8nNeceYngH9Ow++M+VVEwJVpdFmrlxsN22F+ISDCJE= +github.com/opencontainers/selinux v1.13.1/go.mod h1:S10WXZ/osk2kWOYKy1x2f/eXF5ZHJoUs8UU/2caNRbg= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -201,69 +203,66 @@ 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/vmware-tanzu/vm-operator/api v1.9.1-0.20260423003402-51227659e236 h1:Ps9BhhfKiEcvUj/rgapZhR6EG8ciRn1jjUw+f9jIjXQ= github.com/vmware-tanzu/vm-operator/api v1.9.1-0.20260423003402-51227659e236/go.mod h1:nWTPpxfe4gHuuYuFcrs86+NMxfkqPk3a3IlvI8TCWak= -github.com/vmware-tanzu/vm-operator/external/byok v0.0.0-20260528162537-04308ad7298e h1:V+D8tKTVt9izet3r9QVeYvAVEL3aa3U/2BYQ8t5rZ04= -github.com/vmware-tanzu/vm-operator/external/byok v0.0.0-20260528162537-04308ad7298e/go.mod h1:8tiuyYslzjLIUmOlXZuGKQdQP2ZgWGCVhVeyptmZYnk= -github.com/vmware/govmomi v0.54.0 h1:akEKkM9XKMOhTskmdzTLG8JzH+sh61jbFrVPbAzv5IQ= -github.com/vmware/govmomi v0.54.0/go.mod h1:0F3hChqXDrSQQnjfSiCqRE5lPD4aZlbOtKG4uroq2a4= +github.com/vmware-tanzu/vm-operator/external/byok v0.0.0-20260423003402-51227659e236 h1:2aWSkcY11sUh/s1ZTeJrH17UOUN1mtuc+UKZyMItXGo= +github.com/vmware-tanzu/vm-operator/external/byok v0.0.0-20260423003402-51227659e236/go.mod h1:8tiuyYslzjLIUmOlXZuGKQdQP2ZgWGCVhVeyptmZYnk= 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/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= -go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= -go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= -go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +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/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.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= -go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +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.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= -go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= 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.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= -go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +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.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= 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.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= -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/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= 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/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= -google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 h1:XF8+t6QQiS0o9ArVan/HW8Q7cycNPGsJf6GA2nXxYAg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529/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.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -290,8 +289,8 @@ k8s.io/apimachinery v0.36.0 h1:jZyPzhd5Z+3h9vJLt0z9XdzW9VzNzWAUw+P1xZ9PXtQ= k8s.io/apimachinery v0.36.0/go.mod h1:FklypaRJt6n5wUIwWXIP6GJlIpUizTgfo1T/As+Tyxc= k8s.io/apiserver v0.36.0 h1:Jg5OFAENUACByUCg15CmhZAYrr5ZyJ+jodyA1mHl3YE= k8s.io/apiserver v0.36.0/go.mod h1:mHvwdHf+qKEm+1/hYm756SV+oREOKSPnsjagOpx6Vho= -k8s.io/client-go v0.36.1 h1:FN/K8QIT2CEDt+2WB2HnWrUANZ50AP5GII43/SP2JR0= -k8s.io/client-go v0.36.1/go.mod h1:s6rAnCtTGYDQnpNjEhSaISV+2O8jwruZ6m3QOYBFbtU= +k8s.io/client-go v0.36.0 h1:pOYi7C4RHChYjMiHpZSpSbIM6ZxVbRXBy7CuiIwqA3c= +k8s.io/client-go v0.36.0/go.mod h1:ZKKcpwF0aLYfkHFCjillCKaTK/yBkEDHTDXCFY6AS9Y= k8s.io/component-base v0.36.0 h1:hFjEktssxiJhrK1zfybkH4kJOi8iZuF+mIDCqS5+jRo= k8s.io/component-base v0.36.0/go.mod h1:JZvIfcNHk+uck+8LhJzhSBtydWXaZNQwX2OdL+Mnwsk= k8s.io/controller-manager v0.36.0 h1:SQoi2QplC2mI7v+rRRVeHtlQcGJVdz8qE86AN+uIT34= @@ -300,22 +299,22 @@ k8s.io/csi-translation-lib v0.36.0 h1:UFUIHivxitc+7f6Y8fFVQ9UzSTbOKX6wFV38Vxep/1 k8s.io/csi-translation-lib v0.36.0/go.mod h1:SPJ2RxRKxP8dSA6TWNzhctBHuD7I/OD3z5MbREvewS4= k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= -k8s.io/kube-openapi v0.0.0-20260520065146-aa012df4f4af h1:zLXA2Irn14q2/06WMkxViyr7YCPUO2lJ0QYE9Juy5vA= -k8s.io/kube-openapi v0.0.0-20260520065146-aa012df4f4af/go.mod h1:V/QaCUYDa+0QpcHhVVc5l99Uz56wEMEXBSj9oCDkNDY= +k8s.io/kube-openapi v0.0.0-20260414162039-ec9c827d403f h1:4Qiq0YAoQATdgmHALJWz9rJ4fj20pB3xebpB4CFNhYM= +k8s.io/kube-openapi v0.0.0-20260414162039-ec9c827d403f/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= k8s.io/kubernetes v1.36.0 h1:JKaAkgSzI4+ZvNWrNJg56jAkUaOiqgBaJstmi6ycyoU= k8s.io/kubernetes v1.36.0/go.mod h1:MLdeJ3qw2CWH9BFml5GvptxQVQckz54fJOZ/WuixpFE= k8s.io/mount-utils v0.36.0 h1:ufsqGyCoPDh7p+6OIa1wv6oH9GqkQQ8XIfEOVfCV3g0= k8s.io/mount-utils v0.36.0/go.mod h1:+I47UOG6FiUGVSy7VanjU/mQXLShMo3M7xBpGLzCub8= k8s.io/sample-controller v0.36.0 h1:F2tmJmEacFVsnkTj/tNetDM2m0FF45vH6BwzLDfKly8= k8s.io/sample-controller v0.36.0/go.mod h1:IJRfF1h/+EJ+sDsC/p2Jkc05nMV8/bRv3JeLuM5qTiI= -k8s.io/utils v0.0.0-20260507154919-ff6756f316d2 h1:wU4tMEhLGgIbLvXQb1cfN+EcM0wf7zC6CPF+C79jroc= -k8s.io/utils v0.0.0-20260507154919-ff6756f316d2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= -sigs.k8s.io/cluster-api v1.13.2 h1:NVdbVLmh6IyfdtENQAi80AijJf/FjfQLODz/6caDjlc= -sigs.k8s.io/cluster-api v1.13.2/go.mod h1:h7cyiUh+N7sIBkSerqU8cDkYMtRlXVO1c5RoJE1p5+g= -sigs.k8s.io/controller-runtime v0.24.1 h1:miPEwrmirImAvgME1L9qebGHrOnGJoVmVdtOU9fRfo4= -sigs.k8s.io/controller-runtime v0.24.1/go.mod h1:vFkfY5fGt5xAC/sKb8IBFKgWPNKG9OUG29dR8Y2wImw= -sigs.k8s.io/gateway-api v1.5.1 h1:RqVRIlkhLhUO8wOHKTLnTJA6o/1un4po4/6M1nRzdd0= -sigs.k8s.io/gateway-api v1.5.1/go.mod h1:GvCETiaMAlLym5CovLxGjS0NysqFk3+Yuq3/rh6QL2o= +k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 h1:kBawHLSnx/mYHmRnNUf9d4CpjREbeZuxoSGOX/J+aYM= +k8s.io/utils v0.0.0-20260319190234-28399d86e0b5/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +sigs.k8s.io/cluster-api v1.13.1 h1:5qksGznSU1fJOXIxsI4EayTqG1Q9S0qJNp3HdsVm1KU= +sigs.k8s.io/cluster-api v1.13.1/go.mod h1:Hqq5yucu3OwPiAjNEh/O/zZX4dF63MD8Q6I0cwL/bUU= +sigs.k8s.io/controller-runtime v0.24.0 h1:Ck6N2LdS8Lovy1o25BB4r1xjvLEKUl1s2o9kU+KWDE4= +sigs.k8s.io/controller-runtime v0.24.0/go.mod h1:vFkfY5fGt5xAC/sKb8IBFKgWPNKG9OUG29dR8Y2wImw= +sigs.k8s.io/gateway-api v1.5.0 h1:duoo14Ky/fJXpjpmyMISE2RTBGnfCg8zICfTYLTnBJA= +sigs.k8s.io/gateway-api v1.5.0/go.mod h1:GvCETiaMAlLym5CovLxGjS0NysqFk3+Yuq3/rh6QL2o= 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= diff --git a/hack/release.sh b/hack/release.sh index 7dd2e74d75..5b5bcb6089 100755 --- a/hack/release.sh +++ b/hack/release.sh @@ -34,13 +34,12 @@ readonly SYNCER_IMAGE_CI=${BASE_IMAGE_REPO}/syncer PUSH= LATEST= +# These can be set via env vars, -i/-s/-v flags, or left empty for defaults. +CUSTOM_CSI_IMAGE_NAME="${CSI_IMAGE_NAME:-}" +CUSTOM_SYNCER_IMAGE_NAME="${SYNCER_IMAGE_NAME:-}" CSI_IMAGE_NAME= SYNCER_IMAGE_NAME= -if [[ "$(git rev-parse --abbrev-ref HEAD)" =~ "master" ]]; then - VERSION="$(git log -1 --format=%h)" -else - VERSION="$(git describe --always 2>/dev/null)" -fi +VERSION="${VERSION:-}" GIT_COMMIT="$(git log -1 --format=%H)" GCR_KEY_FILE="${GCR_KEY_FILE:-}" GOPROXY="${GOPROXY:-https://proxy.golang.org}" @@ -75,15 +74,21 @@ usage: ${0} [FLAGS] Honored environment variables: GCR_KEY_FILE GOPROXY + VERSION image tag to use (overridden by -v flag) + CSI_IMAGE_NAME CSI driver image name (overridden by -i flag) + SYNCER_IMAGE_NAME syncer image name (overridden by -s flag) FLAGS -h show this help and exit + -i CSI driver image name (e.g. myrepo/driver); overrides CSI_IMAGE_NAME env var -k path to GCR key file. Used to login to registry if specified (defaults to: ${GCR_KEY_FILE}) -l tag the images as \"latest\" in addition to their version when used with -p, both tags will be pushed -p push the images to the public container registry -r push the image to custom registry, specify the registry to be used + -s syncer image name (e.g. myrepo/syncer); overrides SYNCER_IMAGE_NAME env var + -v image tag/version to use (e.g. mytestv1); overrides VERSION env var and git " @@ -130,7 +135,12 @@ function build_driver_images_windows() { function build_driver_images_linux() { echo "building ${CSI_IMAGE_NAME}:${VERSION} for linux" docker buildx rm vsphere-csi-builder-win || echo "builder instance not found, safe to proceed" - tag="${CSI_IMAGE_NAME}-linux-${ARCH}:${VERSION}" + # If a custom image name was provided, use it as-is; otherwise append -linux-${ARCH} + if [ -n "${CUSTOM_CSI_IMAGE_NAME}" ]; then + tag="${CSI_IMAGE_NAME}:${VERSION}" + else + tag="${CSI_IMAGE_NAME}-linux-${ARCH}:${VERSION}" + fi docker buildx build \ --platform "linux/$ARCH" \ --output "${LINUX_IMAGE_OUTPUT}" \ @@ -164,8 +174,8 @@ function build_syncer_image_linux() { } function build_images() { - CSI_IMAGE_NAME=${CSI_IMAGE_CI} - SYNCER_IMAGE_NAME=${SYNCER_IMAGE_CI} + CSI_IMAGE_NAME="${CUSTOM_CSI_IMAGE_NAME:-${CSI_IMAGE_CI}}" + SYNCER_IMAGE_NAME="${CUSTOM_SYNCER_IMAGE_NAME:-${SYNCER_IMAGE_CI}}" LATEST="latest" # build images for linux platform @@ -258,11 +268,14 @@ function push_syncer_images() { } # Start of main script -while getopts ":hk:lpr:" opt; do +while getopts ":hi:k:lpr:s:v:" opt; do case ${opt} in h) error "${USAGE}" && exit 1 ;; + i) + CUSTOM_CSI_IMAGE_NAME="${OPTARG}" + ;; k) GCR_KEY_FILE="${OPTARG}" ;; @@ -275,6 +288,12 @@ while getopts ":hk:lpr:" opt; do r) REGISTRY="${OPTARG}" ;; + s) + CUSTOM_SYNCER_IMAGE_NAME="${OPTARG}" + ;; + v) + VERSION="${OPTARG}" + ;; \?) error "invalid option: -${OPTARG} ${USAGE}" && exit 1 ;; @@ -283,6 +302,15 @@ while getopts ":hk:lpr:" opt; do ;; esac done + +# Resolve VERSION: -v flag or env var wins; fall back to git. +if [ -z "${VERSION}" ]; then + if [[ "$(git rev-parse --abbrev-ref HEAD)" =~ "master" ]]; then + VERSION="$(git log -1 --format=%h)" + else + VERSION="$(git describe --always 2>/dev/null)" + fi +fi shift $((OPTIND-1)) # Verify the GCR_KEY_FILE exists if defined @@ -290,15 +318,17 @@ if [ "${GCR_KEY_FILE}" ]; then [ -e "${GCR_KEY_FILE}" ] || fatal "key file ${GCR_KEY_FILE} does not exist" fi -# Validate build/release type. -case "${BUILD_RELEASE_TYPE}" in - ci|pr|release) - # do nothing - ;; - *) - fatal "invalid BUILD_RELEASE_TYPE: ${BUILD_RELEASE_TYPE}" - ;; -esac +# Validate build/release type when set. +if [ -n "${BUILD_RELEASE_TYPE}" ]; then + case "${BUILD_RELEASE_TYPE}" in + ci|pr|release) + # do nothing + ;; + *) + fatal "invalid BUILD_RELEASE_TYPE: ${BUILD_RELEASE_TYPE}" + ;; + esac +fi mkdir -p .build diff --git a/manifests/supervisorcluster/1.32/cns-csi.yaml b/manifests/supervisorcluster/1.32/cns-csi.yaml index a8c511db09..b390869a5f 100644 --- a/manifests/supervisorcluster/1.32/cns-csi.yaml +++ b/manifests/supervisorcluster/1.32/cns-csi.yaml @@ -129,6 +129,12 @@ rules: - apiGroups: ["cns.vmware.com"] resources: ["cnsvolumeinfoes"] verbs: ["create", "get", "list", "watch", "delete", "patch"] + - apiGroups: ["cns.vmware.com"] + resources: ["csivolumeinfos"] + verbs: ["create", "get", "list", "watch", "delete", "patch", "update"] + - apiGroups: ["cns.vmware.com"] + resources: ["csivolumeinfos/status"] + verbs: ["update", "patch"] - apiGroups: ["crd.nsx.vmware.com"] resources: ["networkinfos"] verbs: ["get", "watch", "list"] @@ -798,6 +804,9 @@ rules: - apiGroups: [""] resources: ["configmaps"] verbs: ["get", "list", "watch"] + - apiGroups: ["cns.vmware.com"] + resources: ["csivolumeinfos"] + verbs: ["get", "list"] --- kind: RoleBinding apiVersion: rbac.authorization.k8s.io/v1 diff --git a/manifests/supervisorcluster/1.33/cns-csi.yaml b/manifests/supervisorcluster/1.33/cns-csi.yaml index a71567fd0c..9304302295 100644 --- a/manifests/supervisorcluster/1.33/cns-csi.yaml +++ b/manifests/supervisorcluster/1.33/cns-csi.yaml @@ -132,6 +132,12 @@ rules: - apiGroups: ["cns.vmware.com"] resources: ["cnsvolumeinfoes/status"] verbs: ["patch"] + - apiGroups: ["cns.vmware.com"] + resources: ["csivolumeinfos"] + verbs: ["create", "get", "list", "watch", "delete", "patch", "update"] + - apiGroups: ["cns.vmware.com"] + resources: ["csivolumeinfos/status"] + verbs: ["update", "patch"] - apiGroups: ["crd.nsx.vmware.com"] resources: ["networkinfos"] verbs: ["get", "watch", "list"] @@ -822,6 +828,9 @@ rules: - apiGroups: [""] resources: ["configmaps"] verbs: ["get", "list", "watch"] + - apiGroups: ["cns.vmware.com"] + resources: ["csivolumeinfos"] + verbs: ["get", "list"] --- kind: RoleBinding apiVersion: rbac.authorization.k8s.io/v1 diff --git a/manifests/supervisorcluster/1.34/cns-csi.yaml b/manifests/supervisorcluster/1.34/cns-csi.yaml index a71567fd0c..9304302295 100644 --- a/manifests/supervisorcluster/1.34/cns-csi.yaml +++ b/manifests/supervisorcluster/1.34/cns-csi.yaml @@ -132,6 +132,12 @@ rules: - apiGroups: ["cns.vmware.com"] resources: ["cnsvolumeinfoes/status"] verbs: ["patch"] + - apiGroups: ["cns.vmware.com"] + resources: ["csivolumeinfos"] + verbs: ["create", "get", "list", "watch", "delete", "patch", "update"] + - apiGroups: ["cns.vmware.com"] + resources: ["csivolumeinfos/status"] + verbs: ["update", "patch"] - apiGroups: ["crd.nsx.vmware.com"] resources: ["networkinfos"] verbs: ["get", "watch", "list"] @@ -822,6 +828,9 @@ rules: - apiGroups: [""] resources: ["configmaps"] verbs: ["get", "list", "watch"] + - apiGroups: ["cns.vmware.com"] + resources: ["csivolumeinfos"] + verbs: ["get", "list"] --- kind: RoleBinding apiVersion: rbac.authorization.k8s.io/v1 diff --git a/manifests/supervisorcluster/1.35/cns-csi.yaml b/manifests/supervisorcluster/1.35/cns-csi.yaml index a71567fd0c..9304302295 100644 --- a/manifests/supervisorcluster/1.35/cns-csi.yaml +++ b/manifests/supervisorcluster/1.35/cns-csi.yaml @@ -132,6 +132,12 @@ rules: - apiGroups: ["cns.vmware.com"] resources: ["cnsvolumeinfoes/status"] verbs: ["patch"] + - apiGroups: ["cns.vmware.com"] + resources: ["csivolumeinfos"] + verbs: ["create", "get", "list", "watch", "delete", "patch", "update"] + - apiGroups: ["cns.vmware.com"] + resources: ["csivolumeinfos/status"] + verbs: ["update", "patch"] - apiGroups: ["crd.nsx.vmware.com"] resources: ["networkinfos"] verbs: ["get", "watch", "list"] @@ -822,6 +828,9 @@ rules: - apiGroups: [""] resources: ["configmaps"] verbs: ["get", "list", "watch"] + - apiGroups: ["cns.vmware.com"] + resources: ["csivolumeinfos"] + verbs: ["get", "list"] --- kind: RoleBinding apiVersion: rbac.authorization.k8s.io/v1 diff --git a/pkg/apis/cnsoperator/cnsregistervolume/v1alpha1/cnsregistervolume_types.go b/pkg/apis/cnsoperator/cnsregistervolume/v1alpha1/cnsregistervolume_types.go index 1ce318bccb..4fba51a177 100644 --- a/pkg/apis/cnsoperator/cnsregistervolume/v1alpha1/cnsregistervolume_types.go +++ b/pkg/apis/cnsoperator/cnsregistervolume/v1alpha1/cnsregistervolume_types.go @@ -65,6 +65,28 @@ type CnsRegisterVolumeSpec struct { // SparseVer2BackingInfo, RawDiskMappingVer1BackingInfo, SeSparseBackingInfo, // LocalPMemBackingInfo, or empty string. BackingType string `json:"backingType,omitempty"` + + // DeferFcdRegistration instructs the controller to create Kubernetes + // bookkeeping (PV and CsiVolumeInfo) using the PVC UID as the volume + // identity, without registering an FCD or creating a CNS DB entry. + // Used when the disk is already attached as a plain VMDK on an imported VM. + // +optional + DeferFcdRegistration bool `json:"deferFcdRegistration,omitempty"` + + // VMName is the name of the VirtualMachine CR that owns this disk. + // Required when DeferFcdRegistration is true. + // +optional + VMName string `json:"vmName,omitempty"` + + // VMInstanceUUID is the instance UUID of the owning VM. + // Required when DeferFcdRegistration is true. + // +optional + VMInstanceUUID string `json:"vmInstanceUUID,omitempty"` + + // DiskUUID is the VirtualDisk backing UUID (VirtualDisk.Backing.Uuid). + // Informational only — not used as a correlation key. + // +optional + DiskUUID string `json:"diskUUID,omitempty"` } // CnsRegisterVolumeStatus defines the observed state of CnsRegisterVolume @@ -79,6 +101,15 @@ type CnsRegisterVolumeStatus struct { // This field must only be set by the entity completing the import // operation, i.e. the CNS Operator. Error string `json:"error,omitempty"` + + // VolumeID is the volume identity assigned by the controller. + // For deferred-FCD imports this equals the PVC UID. + // +optional + VolumeID string `json:"volumeID,omitempty"` + + // PvName is the name of the PersistentVolume created by the controller. + // +optional + PvName string `json:"pvName,omitempty"` } // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object diff --git a/pkg/apis/cnsoperator/config/cnsregistervolume_crd.yaml b/pkg/apis/cnsoperator/config/cnsregistervolume_crd.yaml index e7b2bbf066..1d5c33debd 100644 --- a/pkg/apis/cnsoperator/config/cnsregistervolume_crd.yaml +++ b/pkg/apis/cnsoperator/config/cnsregistervolume_crd.yaml @@ -42,17 +42,13 @@ spec: if VolumeID is specified. type: string diskURLPath: - description: 'DiskUrlPath is URL path to an existing block volume - to be imported into Project Pacific cluster. VolumeID and DiskUrlPath - cannot be specified together. DiskUrlPath is explicitly used for - block volumes. AccessMode need not be specified and will be defaulted - to "ReadWriteOnce". This field must be in the following format: - Format: https:///folder/?dcPath=&dsName= - Ex: https://10.192.255.221/folder/34a9c05d-5f03-e254-e692-02004479cb91/ vm2_1.vmdk?dcPath=Datacenter-1&dsName=vsanDatastore - This is for a 34a9c05d-5f03-e254-e692-02004479cb91/vm2_1.vmdk file - under datacenter "Datacenter-1" and datastore "vsanDatastore".' + description: 'DiskUrlPath is the path to an existing block volume. + Accepts either the vCenter folder URL form + (https:///folder/?dcPath=&dsName=) or the + datastore path form ([] /.vmdk) used for + VM-import where the disk is already attached as a plain VMDK.' type: string - pattern: '^(http[s]?:\/\/)?([^\/\s]+\/folder\/)(.*)$' + pattern: '^((http[s]?:\/\/)?([^\/\s]+\/folder\/)(.*)|\[.+\].+)$' pvcName: description: Name of the PVC type: string @@ -82,6 +78,23 @@ spec: x-kubernetes-validations: - rule: "self == '' || self == 'FlatVer1BackingInfo' || self == 'FlatVer2BackingInfo' || self == 'SparseVer1BackingInfo' || self == 'SparseVer2BackingInfo' || self == 'RawDiskMappingVer1BackingInfo' || self == 'SeSparseBackingInfo' || self == 'LocalPMemBackingInfo'" message: "backingType must be one of: FlatVer1BackingInfo, FlatVer2BackingInfo, SparseVer1BackingInfo, SparseVer2BackingInfo, RawDiskMappingVer1BackingInfo, SeSparseBackingInfo, LocalPMemBackingInfo, or empty string" + deferFcdRegistration: + description: When true, the controller creates PV and CsiVolumeInfo + using the PVC UID as the volume identity without registering an FCD. + Used for VM-imported disks that are already attached as plain VMDKs. + type: boolean + vmName: + description: Name of the VirtualMachine CR that owns this disk. + Required when deferFcdRegistration is true. + type: string + vmInstanceUUID: + description: Instance UUID of the owning VM. + Required when deferFcdRegistration is true. + type: string + diskUUID: + description: VirtualDisk backing UUID (VirtualDisk.Backing.Uuid). + Informational only — not used as a correlation key. + type: string required: - pvcName type: object @@ -98,6 +111,13 @@ spec: field must only be set by the entity completing the register operation, i.e. the CNS Operator. type: boolean + volumeID: + description: Volume identity assigned by the controller. For deferred-FCD + imports this equals the PVC UID. + type: string + pvName: + description: Name of the PersistentVolume created by the controller. + type: string required: - registered type: object diff --git a/pkg/apis/cnsoperator/csivolumeinfo/config/cns.vmware.com_csivolumeinfos.yaml b/pkg/apis/cnsoperator/csivolumeinfo/config/cns.vmware.com_csivolumeinfos.yaml new file mode 100644 index 0000000000..2a423ed1e8 --- /dev/null +++ b/pkg/apis/cnsoperator/csivolumeinfo/config/cns.vmware.com_csivolumeinfos.yaml @@ -0,0 +1,184 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.14.0 + creationTimestamp: null + name: csivolumeinfos.cns.vmware.com +spec: + group: cns.vmware.com + names: + kind: CsiVolumeInfo + listKind: CsiVolumeInfoList + plural: csivolumeinfos + shortNames: + - cvi + singular: csivolumeinfo + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.ownership + name: Ownership + type: string + - jsonPath: .status.phase + name: Phase + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: CsiVolumeInfo tracks the ownership lifecycle of a CSI volume + for the VM-owned volume attach/detach model. One CR exists per PV in the + vmware-system-csi namespace. + properties: + apiVersion: + description: APIVersion defines the versioned schema of this representation + of an object. + type: string + kind: + description: Kind is a string value representing the REST resource this + object represents. + type: string + metadata: + type: object + spec: + description: CsiVolumeInfoSpec defines the desired state of CsiVolumeInfo. + properties: + diskPath: + description: DiskPath is the datastore path to the VMDK file. + type: string + diskUUID: + description: DiskUUID is the stable identifier of the virtual disk. + type: string + pvName: + description: PVName is the name of the bound PersistentVolume. + type: string + pvcName: + description: PVCName is the name of the bound PVC. + type: string + pvcNamespace: + description: PVCNamespace is the namespace of the bound PVC. + type: string + volumeID: + description: VolumeID is the CNS volume ID / PV volumeHandle. Immutable + after creation. + minLength: 1 + type: string + vms: + description: VMs lists the VirtualMachine CRs that have this volume + attached. + items: + description: VirtualMachineRef identifies a VM attached to the volume. + properties: + vmInstanceUUID: + description: VMInstanceUUID is the instance UUID of the VM. + type: string + vmName: + description: VMName is the VirtualMachine CR name. + type: string + required: + - vmName + type: object + type: array + x-kubernetes-list-map-keys: + - vmName + x-kubernetes-list-type: map + required: + - pvName + - pvcName + - pvcNamespace + - volumeID + type: object + status: + description: CsiVolumeInfoStatus defines the observed state of CsiVolumeInfo. + properties: + conditions: + description: Conditions is a standard K8s condition array. + 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. + format: date-time + type: string + message: + description: message is a human readable message indicating + details about the transition. + maxLength: 32768 + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation + that the condition was set based upon. + format: int64 + minimum: 0 + type: integer + reason: + description: reason contains a programmatic identifier indicating + the reason for the condition's last transition. + 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. + 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 + error: + description: Error holds the last reconcile error message, if any. + type: string + observedGeneration: + description: ObservedGeneration is the spec.generation last acted + on by the controller. + format: int64 + type: integer + ownership: + description: Ownership is the current ownership state of the volume. + enum: + - CSIManaged + - VMManaged + type: string + phase: + description: Phase reflects the last reconcile outcome. + enum: + - Pending + - Succeeded + - Failed + type: string + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} +status: + acceptedNames: + kind: "" + plural: "" + conditions: [] + storedVersions: [] diff --git a/pkg/apis/cnsoperator/csivolumeinfo/config/config.go b/pkg/apis/cnsoperator/csivolumeinfo/config/config.go new file mode 100644 index 0000000000..cacc9d9f03 --- /dev/null +++ b/pkg/apis/cnsoperator/csivolumeinfo/config/config.go @@ -0,0 +1,28 @@ +/* +Copyright 2026 The Kubernetes Authors. + +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 config embeds the CsiVolumeInfo CRD manifest for use at runtime. +package config + +import "embed" + +// EmbedCsiVolumeInfoCRFile embeds the CsiVolumeInfo CRD YAML. +// +//go:embed cns.vmware.com_csivolumeinfos.yaml +var EmbedCsiVolumeInfoCRFile embed.FS + +// EmbedCsiVolumeInfoCRFileName is the filename of the embedded CsiVolumeInfo CRD. +const EmbedCsiVolumeInfoCRFileName = "cns.vmware.com_csivolumeinfos.yaml" diff --git a/pkg/apis/cnsoperator/csivolumeinfo/csivolumeinfoservice.go b/pkg/apis/cnsoperator/csivolumeinfo/csivolumeinfoservice.go new file mode 100644 index 0000000000..89a28bd556 --- /dev/null +++ b/pkg/apis/cnsoperator/csivolumeinfo/csivolumeinfoservice.go @@ -0,0 +1,390 @@ +/* +Copyright 2026 The Kubernetes Authors. + +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 csivolumeinfo provides the service layer for CsiVolumeInfo CRs. +// CsiVolumeInfo CRs live in the vmware-system-csi namespace and track the +// per-volume ownership lifecycle for the VM-owned volume attach/detach model. +package csivolumeinfo + +import ( + "context" + "fmt" + "time" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + k8stypes "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + csivolumeinfov1alpha1 "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/csivolumeinfo/v1alpha1" + "sigs.k8s.io/vsphere-csi-driver/v3/pkg/csi/service/logger" + k8s "sigs.k8s.io/vsphere-csi-driver/v3/pkg/kubernetes" +) + +const ( + // allowedRetries is the default number of patch retries on conflict. + allowedRetries = 5 +) + +// CsiVolumeInfoService exposes CRUD operations on CsiVolumeInfo CRs. +// All CRs live in the vmware-system-csi namespace (csivolumeinfov1alpha1.CVINamespace). +// The service uses a typed controller-runtime client for type safety. +type CsiVolumeInfoService interface { + // CreateCsiVolumeInfo creates a new CsiVolumeInfo CR. + // Returns nil if a CR with the same name already exists (idempotent). + CreateCsiVolumeInfo(ctx context.Context, cvi *csivolumeinfov1alpha1.CsiVolumeInfo) error + + // GetCsiVolumeInfo fetches a CsiVolumeInfo by deterministic name derived + // from volumeID (cns-volume-) in the vmware-system-csi namespace. + // Returns nil and no error if not found. + GetCsiVolumeInfo(ctx context.Context, volumeID string) (*csivolumeinfov1alpha1.CsiVolumeInfo, error) + + // UpdateCsiVolumeInfoStatus replaces the status subresource of the given + // CsiVolumeInfo object. The object must have been fetched from the API + // server (its ResourceVersion is used for optimistic concurrency). + UpdateCsiVolumeInfoStatus(ctx context.Context, cvi *csivolumeinfov1alpha1.CsiVolumeInfo) error + + // PatchCsiVolumeInfo applies a JSON merge-patch to the spec and metadata + // (not status) of the CsiVolumeInfo identified by volumeID. + // Retries up to allowedRetries times on conflict. + // + // It returns the metadata.generation of the object after the patch is + // applied. Because a spec change increments generation, callers that need + // to record observedGeneration must use this returned value (not the + // pre-patch generation) so that observedGeneration reflects the spec the + // controller actually acted on. + PatchCsiVolumeInfo(ctx context.Context, volumeID string, patchBytes []byte) (int64, error) + + // PatchCsiVolumeInfoStatus applies a JSON merge-patch to the status + // subresource of the CsiVolumeInfo identified by volumeID. + // Retries up to allowedRetries times on conflict. + PatchCsiVolumeInfoStatus(ctx context.Context, volumeID string, patchBytes []byte) error + + // DeleteCsiVolumeInfo deletes the CsiVolumeInfo for the given volumeID. + // Returns nil if the CR is already gone (idempotent). + DeleteCsiVolumeInfo(ctx context.Context, volumeID string) error + + // CsiVolumeInfoExists reports whether a CsiVolumeInfo CR exists for the + // given volumeID. + CsiVolumeInfoExists(ctx context.Context, volumeID string) (bool, error) + + // AddVolumeProtectionFinalizer adds the volume-protection finalizer to the + // CsiVolumeInfo for the given volumeID. Idempotent: no-op if already present. + AddVolumeProtectionFinalizer(ctx context.Context, volumeID string) error + + // RemoveVolumeProtectionFinalizer removes the volume-protection finalizer from the + // CsiVolumeInfo for the given volumeID. Idempotent: no-op if already absent. + RemoveVolumeProtectionFinalizer(ctx context.Context, volumeID string) error +} + +// csiVolumeInfoSvc is the concrete singleton implementing CsiVolumeInfoService. +type csiVolumeInfoSvc struct { + k8sClient client.Client +} + +var ( + // serviceInstance is the package-level singleton. + serviceInstance *csiVolumeInfoSvc +) + +// GetCsiVolumeInfoCRName returns the deterministic CR name for a volumeID. +// Name format: cns-volume-. +func GetCsiVolumeInfoCRName(volumeID string) string { + return csivolumeinfov1alpha1.CVINamePrefix + volumeID +} + +// InitCsiVolumeInfoService initialises (idempotent) the CsiVolumeInfo service +// by building a controller-runtime client for the cns.vmware.com group. +// +// The CsiVolumeInfo CRD must already exist before calling this function. +// Callers must hold no lock; internal state is initialised once and is +// safe for concurrent reads afterward. +func InitCsiVolumeInfoService(ctx context.Context) (CsiVolumeInfoService, error) { + log := logger.GetLogger(ctx) + if serviceInstance != nil { + return serviceInstance, nil + } + + log.Info("Initializing CsiVolumeInfo service...") + + config, err := k8s.GetKubeConfig(ctx) + if err != nil { + return nil, logger.LogNewErrorf(log, + "failed to get kubeconfig for CsiVolumeInfo service. err: %v", err) + } + + k8sClient, err := k8s.NewClientForGroup(ctx, config, + csivolumeinfov1alpha1.GroupName) + if err != nil { + return nil, logger.LogNewErrorf(log, + "failed to create k8s client for CsiVolumeInfo service. err: %v", err) + } + + serviceInstance = &csiVolumeInfoSvc{k8sClient: k8sClient} + log.Info("CsiVolumeInfo service initialized") + return serviceInstance, nil +} + +// NewCsiVolumeInfoService creates a CsiVolumeInfoService backed by the given client. +// Use this constructor in tests and controllers where a client is already available. +func NewCsiVolumeInfoService(k8sClient client.Client) CsiVolumeInfoService { + return &csiVolumeInfoSvc{k8sClient: k8sClient} +} + +// CreateCsiVolumeInfo creates a new CsiVolumeInfo CR. +// AlreadyExists errors are treated as success (idempotent). +// Because the CRD declares a status subresource, the API server strips the +// status block on Create; callers that need initial status set should call +// PatchCsiVolumeInfoStatus after creation. +func (s *csiVolumeInfoSvc) CreateCsiVolumeInfo( + ctx context.Context, cvi *csivolumeinfov1alpha1.CsiVolumeInfo) error { + log := logger.GetLogger(ctx) + log.Infof("Creating CsiVolumeInfo %s/%s", cvi.Namespace, cvi.Name) + + if err := s.k8sClient.Create(ctx, cvi); err != nil { + if apierrors.IsAlreadyExists(err) { + log.Infof("CsiVolumeInfo %s/%s already exists", cvi.Namespace, cvi.Name) + return nil + } + return logger.LogNewErrorf(log, + "failed to create CsiVolumeInfo %s/%s: %v", cvi.Namespace, cvi.Name, err) + } + + log.Infof("Successfully created CsiVolumeInfo %s/%s", cvi.Namespace, cvi.Name) + return nil +} + +// GetCsiVolumeInfo fetches a CsiVolumeInfo by volumeID in the vmware-system-csi namespace. +// Returns nil and no error if the CR does not exist. +func (s *csiVolumeInfoSvc) GetCsiVolumeInfo( + ctx context.Context, volumeID string) (*csivolumeinfov1alpha1.CsiVolumeInfo, error) { + log := logger.GetLogger(ctx) + name := GetCsiVolumeInfoCRName(volumeID) + cvi := &csivolumeinfov1alpha1.CsiVolumeInfo{} + if err := s.k8sClient.Get(ctx, k8stypes.NamespacedName{ + Namespace: csivolumeinfov1alpha1.CVINamespace, + Name: name, + }, cvi); err != nil { + if apierrors.IsNotFound(err) { + return nil, nil + } + return nil, logger.LogNewErrorf(log, + "failed to get CsiVolumeInfo %s/%s: %v", csivolumeinfov1alpha1.CVINamespace, name, err) + } + return cvi, nil +} + +// UpdateCsiVolumeInfoStatus replaces the status subresource. +func (s *csiVolumeInfoSvc) UpdateCsiVolumeInfoStatus( + ctx context.Context, cvi *csivolumeinfov1alpha1.CsiVolumeInfo) error { + log := logger.GetLogger(ctx) + if err := s.k8sClient.Status().Update(ctx, cvi); err != nil { + return logger.LogNewErrorf(log, + "failed to update status of CsiVolumeInfo %s/%s: %v", + cvi.Namespace, cvi.Name, err) + } + log.Infof("Successfully updated status of CsiVolumeInfo %s/%s (ownership=%s)", + cvi.Namespace, cvi.Name, cvi.Status.Ownership) + return nil +} + +// PatchCsiVolumeInfo applies a JSON merge-patch to the CsiVolumeInfo for the +// given volumeID. Only conflicts are retried (up to allowedRetries times); any +// other error is returned immediately because retrying it would not help. +// +// The returned int64 is the object's metadata.generation after the patch. The +// API server populates the response object on a successful patch, so this value +// reflects any generation increment caused by the spec change. +func (s *csiVolumeInfoSvc) PatchCsiVolumeInfo( + ctx context.Context, volumeID string, patchBytes []byte) (int64, error) { + log := logger.GetLogger(ctx) + name := GetCsiVolumeInfoCRName(volumeID) + + cvi := &csivolumeinfov1alpha1.CsiVolumeInfo{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: csivolumeinfov1alpha1.CVINamespace, + Name: name, + }, + } + + var lastErr error + for attempt := 1; attempt <= allowedRetries; attempt++ { + err := s.k8sClient.Patch(ctx, cvi, + client.RawPatch(k8stypes.MergePatchType, patchBytes)) + if err == nil { + log.Infof("attempt %d: successfully patched CsiVolumeInfo %s/%s (generation=%d)", + attempt, csivolumeinfov1alpha1.CVINamespace, name, cvi.Generation) + return cvi.Generation, nil + } + lastErr = err + if !apierrors.IsConflict(err) { + return 0, logger.LogNewErrorf(log, + "failed to patch CsiVolumeInfo %s/%s: %v", + csivolumeinfov1alpha1.CVINamespace, name, err) + } + log.Warnf("attempt %d: conflict patching CsiVolumeInfo %s/%s: %v", + attempt, csivolumeinfov1alpha1.CVINamespace, name, err) + time.Sleep(100 * time.Millisecond) + } + return 0, logger.LogNewErrorf(log, + "failed to patch CsiVolumeInfo %s/%s after %d conflict retries: %v", + csivolumeinfov1alpha1.CVINamespace, name, allowedRetries, lastErr) +} + +// PatchCsiVolumeInfoStatus applies a JSON merge-patch to the status subresource +// of the CsiVolumeInfo for the given volumeID. Retries on conflict up to +// allowedRetries times. +func (s *csiVolumeInfoSvc) PatchCsiVolumeInfoStatus( + ctx context.Context, volumeID string, patchBytes []byte) error { + log := logger.GetLogger(ctx) + name := GetCsiVolumeInfoCRName(volumeID) + + cvi := &csivolumeinfov1alpha1.CsiVolumeInfo{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: csivolumeinfov1alpha1.CVINamespace, + Name: name, + }, + } + + var lastErr error + for attempt := 1; attempt <= allowedRetries; attempt++ { + err := s.k8sClient.Status().Patch(ctx, cvi, + client.RawPatch(k8stypes.MergePatchType, patchBytes)) + if err == nil { + log.Infof("attempt %d: successfully patched status of CsiVolumeInfo %s/%s", + attempt, csivolumeinfov1alpha1.CVINamespace, name) + return nil + } + lastErr = err + if !apierrors.IsConflict(err) { + return logger.LogNewErrorf(log, + "failed to patch status of CsiVolumeInfo %s/%s: %v", + csivolumeinfov1alpha1.CVINamespace, name, err) + } + log.Warnf("attempt %d: conflict patching status of CsiVolumeInfo %s/%s: %v", + attempt, csivolumeinfov1alpha1.CVINamespace, name, err) + time.Sleep(100 * time.Millisecond) + } + return logger.LogNewErrorf(log, + "failed to patch status of CsiVolumeInfo %s/%s after %d conflict retries: %v", + csivolumeinfov1alpha1.CVINamespace, name, allowedRetries, lastErr) +} + +// DeleteCsiVolumeInfo deletes the CsiVolumeInfo for the given volumeID. +// NotFound errors are treated as success (idempotent). +func (s *csiVolumeInfoSvc) DeleteCsiVolumeInfo( + ctx context.Context, volumeID string) error { + log := logger.GetLogger(ctx) + name := GetCsiVolumeInfoCRName(volumeID) + + cvi := &csivolumeinfov1alpha1.CsiVolumeInfo{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: csivolumeinfov1alpha1.CVINamespace, + Name: name, + }, + } + if err := s.k8sClient.Delete(ctx, cvi); err != nil { + if apierrors.IsNotFound(err) { + log.Infof("CsiVolumeInfo %s/%s already deleted", + csivolumeinfov1alpha1.CVINamespace, name) + return nil + } + return logger.LogNewErrorf(log, + "failed to delete CsiVolumeInfo %s/%s: %v", + csivolumeinfov1alpha1.CVINamespace, name, err) + } + log.Infof("Successfully deleted CsiVolumeInfo %s/%s", + csivolumeinfov1alpha1.CVINamespace, name) + return nil +} + +// CsiVolumeInfoExists reports whether a CsiVolumeInfo CR exists for volumeID. +func (s *csiVolumeInfoSvc) CsiVolumeInfoExists( + ctx context.Context, volumeID string) (bool, error) { + cvi, err := s.GetCsiVolumeInfo(ctx, volumeID) + if err != nil { + return false, fmt.Errorf("CsiVolumeInfoExists: %w", err) + } + return cvi != nil, nil +} + +// AddVolumeProtectionFinalizer adds the volume-protection finalizer to the CsiVolumeInfo +// identified by volumeID. The operation is idempotent. +func (s *csiVolumeInfoSvc) AddVolumeProtectionFinalizer( + ctx context.Context, volumeID string) error { + log := logger.GetLogger(ctx) + name := GetCsiVolumeInfoCRName(volumeID) + + cvi, err := s.GetCsiVolumeInfo(ctx, volumeID) + if err != nil { + return fmt.Errorf("AddVolumeProtectionFinalizer: failed to fetch CsiVolumeInfo %s/%s: %w", + csivolumeinfov1alpha1.CVINamespace, name, err) + } + if cvi == nil { + return fmt.Errorf("AddVolumeProtectionFinalizer: CsiVolumeInfo %s/%s not found", + csivolumeinfov1alpha1.CVINamespace, name) + } + if controllerutil.ContainsFinalizer(cvi, csivolumeinfov1alpha1.VolumeProtectionFinalizer) { + log.Infof("AddVolumeProtectionFinalizer: finalizer already present on CsiVolumeInfo %s/%s", + csivolumeinfov1alpha1.CVINamespace, name) + return nil + } + + patch := client.MergeFrom(cvi.DeepCopy()) + controllerutil.AddFinalizer(cvi, csivolumeinfov1alpha1.VolumeProtectionFinalizer) + if err := s.k8sClient.Patch(ctx, cvi, patch); err != nil { + return fmt.Errorf("AddVolumeProtectionFinalizer: failed to patch CsiVolumeInfo %s/%s: %w", + csivolumeinfov1alpha1.CVINamespace, name, err) + } + log.Infof("AddVolumeProtectionFinalizer: added finalizer on CsiVolumeInfo %s/%s", + csivolumeinfov1alpha1.CVINamespace, name) + return nil +} + +// RemoveVolumeProtectionFinalizer removes the volume-protection finalizer from the +// CsiVolumeInfo identified by volumeID. The operation is idempotent. +func (s *csiVolumeInfoSvc) RemoveVolumeProtectionFinalizer( + ctx context.Context, volumeID string) error { + log := logger.GetLogger(ctx) + name := GetCsiVolumeInfoCRName(volumeID) + + cvi, err := s.GetCsiVolumeInfo(ctx, volumeID) + if err != nil { + return fmt.Errorf("RemoveVolumeProtectionFinalizer: failed to fetch CsiVolumeInfo %s/%s: %w", + csivolumeinfov1alpha1.CVINamespace, name, err) + } + if cvi == nil { + log.Infof("RemoveVolumeProtectionFinalizer: CsiVolumeInfo %s/%s not found; no-op", + csivolumeinfov1alpha1.CVINamespace, name) + return nil + } + if !controllerutil.ContainsFinalizer(cvi, csivolumeinfov1alpha1.VolumeProtectionFinalizer) { + log.Infof("RemoveVolumeProtectionFinalizer: finalizer absent on CsiVolumeInfo %s/%s; no-op", + csivolumeinfov1alpha1.CVINamespace, name) + return nil + } + + patch := client.MergeFrom(cvi.DeepCopy()) + controllerutil.RemoveFinalizer(cvi, csivolumeinfov1alpha1.VolumeProtectionFinalizer) + if err := s.k8sClient.Patch(ctx, cvi, patch); err != nil { + return fmt.Errorf("RemoveVolumeProtectionFinalizer: failed to patch CsiVolumeInfo %s/%s: %w", + csivolumeinfov1alpha1.CVINamespace, name, err) + } + log.Infof("RemoveVolumeProtectionFinalizer: removed finalizer from CsiVolumeInfo %s/%s", + csivolumeinfov1alpha1.CVINamespace, name) + return nil +} diff --git a/pkg/apis/cnsoperator/csivolumeinfo/csivolumeinfoservice_test.go b/pkg/apis/cnsoperator/csivolumeinfo/csivolumeinfoservice_test.go new file mode 100644 index 0000000000..e5ea38f2b3 --- /dev/null +++ b/pkg/apis/cnsoperator/csivolumeinfo/csivolumeinfoservice_test.go @@ -0,0 +1,256 @@ +/* +Copyright 2026 The Kubernetes Authors. + +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 csivolumeinfo_test + +import ( + "context" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + csivolumeinfo "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/csivolumeinfo" + csivolumeinfov1alpha1 "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/csivolumeinfo/v1alpha1" +) + +// newScheme returns a runtime.Scheme with CsiVolumeInfo types registered. +func newScheme(t *testing.T) *runtime.Scheme { + t.Helper() + s := runtime.NewScheme() + if err := csivolumeinfov1alpha1.AddToScheme(s); err != nil { + t.Fatalf("AddToScheme: %v", err) + } + return s +} + +// makeCVI returns a minimal CsiVolumeInfo ready for creation. +func makeCVI(volumeID string) *csivolumeinfov1alpha1.CsiVolumeInfo { + return &csivolumeinfov1alpha1.CsiVolumeInfo{ + ObjectMeta: metav1.ObjectMeta{ + Name: csivolumeinfo.GetCsiVolumeInfoCRName(volumeID), + Namespace: csivolumeinfov1alpha1.CVINamespace, + }, + Spec: csivolumeinfov1alpha1.CsiVolumeInfoSpec{ + VolumeID: volumeID, + PVCName: "test-pvc", + PVCNamespace: "default", + PVName: "pv-" + volumeID, + }, + } +} + +func TestGetCsiVolumeInfoCRName(t *testing.T) { + cases := []struct { + volumeID string + want string + }{ + {"abc-123", "cns-volume-abc-123"}, + {"", "cns-volume-"}, + } + for _, tc := range cases { + got := csivolumeinfo.GetCsiVolumeInfoCRName(tc.volumeID) + if got != tc.want { + t.Errorf("GetCsiVolumeInfoCRName(%q) = %q; want %q", tc.volumeID, got, tc.want) + } + } +} + +func TestFSSConstantValue(t *testing.T) { + // Verify the constant matches the agreed WCP capability string. + const wantFSS = "supports_vm_owned_volumes" + // We import the constant indirectly via the package string value test. + // If the constant changes, this test catches the regression. + if csivolumeinfov1alpha1.CVINamespace != "vmware-system-csi" { + t.Errorf("CVINamespace = %q; want %q", + csivolumeinfov1alpha1.CVINamespace, "vmware-system-csi") + } + if csivolumeinfov1alpha1.CVINamePrefix != "cns-volume-" { + t.Errorf("CVINamePrefix = %q; want %q", + csivolumeinfov1alpha1.CVINamePrefix, "cns-volume-") + } + _ = wantFSS +} + +func TestDeepCopyRoundTrip(t *testing.T) { + orig := makeCVI("vol-round-trip") + orig.Spec.VMs = []csivolumeinfov1alpha1.VirtualMachineRef{ + {VMName: "vm-1", VMInstanceUUID: "uuid-1"}, + {VMName: "vm-2"}, + } + orig.Status.Ownership = csivolumeinfov1alpha1.OwnershipStateVMManaged + orig.Status.Conditions = []metav1.Condition{ + { + Type: "Ready", + Status: metav1.ConditionTrue, + LastTransitionTime: metav1.Now(), + Reason: "Reconciled", + Message: "ok", + }, + } + + copy := orig.DeepCopy() + if copy == orig { + t.Fatal("DeepCopy returned the same pointer") + } + if len(copy.Spec.VMs) != len(orig.Spec.VMs) { + t.Errorf("Spec.VMs len mismatch: got %d, want %d", + len(copy.Spec.VMs), len(orig.Spec.VMs)) + } + if len(copy.Status.Conditions) != len(orig.Status.Conditions) { + t.Errorf("Status.Conditions len mismatch") + } + // Mutation isolation + copy.Spec.VMs[0].VMName = "mutated" + if orig.Spec.VMs[0].VMName == "mutated" { + t.Error("DeepCopy is not isolated: mutating copy affected orig") + } +} + +func TestCsiVolumeInfoService_CreateGetDelete(t *testing.T) { + ctx := context.Background() + svc := csivolumeinfo.NewCsiVolumeInfoService( + fake.NewClientBuilder().WithScheme(newScheme(t)).Build()) + + volumeID := "test-vol-001" + cvi := makeCVI(volumeID) + + // Create + if err := svc.CreateCsiVolumeInfo(ctx, cvi); err != nil { + t.Fatalf("CreateCsiVolumeInfo: %v", err) + } + + // Idempotent create — use a fresh object (no ResourceVersion) to simulate a + // second caller that doesn't have the server-assigned metadata yet. + if err := svc.CreateCsiVolumeInfo(ctx, makeCVI(volumeID)); err != nil { + t.Fatalf("idempotent CreateCsiVolumeInfo: %v", err) + } + + // Get + got, err := svc.GetCsiVolumeInfo(ctx, volumeID) + if err != nil { + t.Fatalf("GetCsiVolumeInfo: %v", err) + } + if got == nil { + t.Fatal("GetCsiVolumeInfo returned nil after create") + } + if got.Spec.VolumeID != volumeID { + t.Errorf("VolumeID = %q; want %q", got.Spec.VolumeID, volumeID) + } + + // Exists + exists, err := svc.CsiVolumeInfoExists(ctx, volumeID) + if err != nil { + t.Fatalf("CsiVolumeInfoExists: %v", err) + } + if !exists { + t.Error("CsiVolumeInfoExists = false; want true") + } + + // Delete + if err := svc.DeleteCsiVolumeInfo(ctx, volumeID); err != nil { + t.Fatalf("DeleteCsiVolumeInfo: %v", err) + } + + // Idempotent delete + if err := svc.DeleteCsiVolumeInfo(ctx, volumeID); err != nil { + t.Fatalf("idempotent DeleteCsiVolumeInfo: %v", err) + } + + // Get after delete + got, err = svc.GetCsiVolumeInfo(ctx, volumeID) + if err != nil { + t.Fatalf("GetCsiVolumeInfo after delete: %v", err) + } + if got != nil { + t.Error("GetCsiVolumeInfo returned non-nil after delete") + } +} + +func TestCsiVolumeInfoService_Finalizers(t *testing.T) { + ctx := context.Background() + svc := csivolumeinfo.NewCsiVolumeInfoService( + fake.NewClientBuilder().WithScheme(newScheme(t)).Build()) + + volumeID := "test-vol-finalizer" + cvi := makeCVI(volumeID) + + if err := svc.CreateCsiVolumeInfo(ctx, cvi); err != nil { + t.Fatalf("CreateCsiVolumeInfo: %v", err) + } + + // Add finalizer + if err := svc.AddVolumeProtectionFinalizer(ctx, volumeID); err != nil { + t.Fatalf("AddVolumeProtectionFinalizer: %v", err) + } + + // Idempotent add + if err := svc.AddVolumeProtectionFinalizer(ctx, volumeID); err != nil { + t.Fatalf("idempotent AddVolumeProtectionFinalizer: %v", err) + } + + // Verify finalizer is present + got, err := svc.GetCsiVolumeInfo(ctx, volumeID) + if err != nil || got == nil { + t.Fatalf("GetCsiVolumeInfo after add finalizer: err=%v got=%v", err, got) + } + found := false + for _, f := range got.Finalizers { + if f == csivolumeinfov1alpha1.VolumeProtectionFinalizer { + found = true + } + } + if !found { + t.Errorf("finalizer %q not found; got %v", + csivolumeinfov1alpha1.VolumeProtectionFinalizer, got.Finalizers) + } + + // Remove finalizer + if err := svc.RemoveVolumeProtectionFinalizer(ctx, volumeID); err != nil { + t.Fatalf("RemoveVolumeProtectionFinalizer: %v", err) + } + + // Idempotent remove + if err := svc.RemoveVolumeProtectionFinalizer(ctx, volumeID); err != nil { + t.Fatalf("idempotent RemoveVolumeProtectionFinalizer: %v", err) + } + + // Verify finalizer removed + got, err = svc.GetCsiVolumeInfo(ctx, volumeID) + if err != nil || got == nil { + t.Fatalf("GetCsiVolumeInfo after remove finalizer: err=%v got=%v", err, got) + } + for _, f := range got.Finalizers { + if f == csivolumeinfov1alpha1.VolumeProtectionFinalizer { + t.Errorf("finalizer %q still present after remove", f) + } + } +} + +func TestCsiVolumeInfoService_GetNotFound(t *testing.T) { + ctx := context.Background() + svc := csivolumeinfo.NewCsiVolumeInfoService( + fake.NewClientBuilder().WithScheme(newScheme(t)).Build()) + + got, err := svc.GetCsiVolumeInfo(ctx, "nonexistent-vol") + if err != nil { + t.Fatalf("GetCsiVolumeInfo for missing CR should return nil error, got: %v", err) + } + if got != nil { + t.Errorf("GetCsiVolumeInfo for missing CR should return nil, got: %+v", got) + } +} diff --git a/pkg/apis/cnsoperator/csivolumeinfo/pending_unregister_recovery.go b/pkg/apis/cnsoperator/csivolumeinfo/pending_unregister_recovery.go new file mode 100644 index 0000000000..085c073140 --- /dev/null +++ b/pkg/apis/cnsoperator/csivolumeinfo/pending_unregister_recovery.go @@ -0,0 +1,190 @@ +/* +Copyright 2026 The Kubernetes Authors. + +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 csivolumeinfo + +import ( + "context" + "encoding/json" + "fmt" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + csivolumeinfov1alpha1 "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/csivolumeinfo/v1alpha1" + volumes "sigs.k8s.io/vsphere-csi-driver/v3/pkg/common/cns-lib/volume" + "sigs.k8s.io/vsphere-csi-driver/v3/pkg/csi/service/logger" +) + +const ( + // conditionTypeReady is the standard Ready condition type on CsiVolumeInfo. + // It mirrors the condition vocabulary written by the reconciler so recovery + // produces an identical status shape. + conditionTypeReady = "Ready" + // reasonUnregisterSucceeded is the Ready-condition reason after a completed unregister. + reasonUnregisterSucceeded = "UnregisterSucceeded" +) + +// RecoverPendingUnregisters is called once at CSI syncer startup to complete +// any two-phase FCD unregister operations that were interrupted by a crash. +// +// Each PENDING_UNREGISTER record from CNS represents an FCD that was removed +// from the catalog but whose acknowledgement was not yet sent. This function +// writes the disk coordinates to the CsiVolumeInfo spec, advances its status +// to VMManaged, then sends the acknowledgement to clean up the CNS row. +// +// Per-record errors are logged and do not abort recovery for subsequent +// records. The function is idempotent: re-running on an already-recovered +// volume is a safe no-op. +func RecoverPendingUnregisters(ctx context.Context, + volumeManager volumes.Manager, + cviSvc CsiVolumeInfoService, +) error { + log := logger.GetLogger(ctx) + log.Infof("RecoverPendingUnregisters: scanning for incomplete two-phase unregister operations") + + records, err := volumeManager.QueryPendingUnregisters(ctx) + if err != nil { + return fmt.Errorf("RecoverPendingUnregisters: QueryPendingUnregisters failed: %w", err) + } + if len(records) == 0 { + log.Infof("RecoverPendingUnregisters: no pending unregister records found") + return nil + } + log.Infof("RecoverPendingUnregisters: found %d pending record(s)", len(records)) + + recovered := 0 + for _, rec := range records { + if recErr := recoverOneRecord(ctx, volumeManager, cviSvc, rec); recErr != nil { + log.Errorf("RecoverPendingUnregisters: failed to recover volume %q: %v", + rec.VolumeID, recErr) + continue + } + recovered++ + } + log.Infof("RecoverPendingUnregisters: completed — recovered %d of %d record(s)", + recovered, len(records)) + return nil +} + +// recoverOneRecord handles crash-recovery for a single PENDING_UNREGISTER record. +func recoverOneRecord( + ctx context.Context, + volumeManager volumes.Manager, + cviSvc CsiVolumeInfoService, + rec volumes.PendingUnregisterRecord, +) error { + log := logger.GetLogger(ctx) + log.Infof("recoverOneRecord: recovering volume %q (diskPath=%q, diskUUID=%q)", + rec.VolumeID, rec.BackingDiskPath, rec.DiskUUID) + + cvi, err := cviSvc.GetCsiVolumeInfo(ctx, rec.VolumeID) + if err != nil { + return fmt.Errorf("recoverOneRecord: failed to get CsiVolumeInfo for volume %q: %w", + rec.VolumeID, err) + } + + if cvi == nil { + // No CsiVolumeInfo found: the volume was deleted while CSI was down. + // Acknowledge the CNS row to clean up the stale PENDING_UNREGISTER entry. + log.Warnf("recoverOneRecord: no CsiVolumeInfo found for volume %q; "+ + "acknowledging CNS row to clean up orphan record", rec.VolumeID) + if ackErr := volumeManager.AckUnregister(ctx, rec.VolumeID); ackErr != nil { + return fmt.Errorf("recoverOneRecord: AckUnregister failed for orphan volume %q: %w", + rec.VolumeID, ackErr) + } + return nil + } + + // If the CVI is already VMManaged, the crash occurred after the status + // was written but before the acknowledgement. The double-ACK is safe. + if cvi.Status.Ownership == csivolumeinfov1alpha1.OwnershipStateVMManaged { + log.Infof("recoverOneRecord: CsiVolumeInfo for volume %q is already VMManaged; "+ + "sending acknowledgement (double-ACK is safe)", rec.VolumeID) + if ackErr := volumeManager.AckUnregister(ctx, rec.VolumeID); ackErr != nil { + return fmt.Errorf("recoverOneRecord: AckUnregister (double) failed for volume %q: %w", + rec.VolumeID, ackErr) + } + return nil + } + + // The crash happened before or during the spec/status write. Persist disk + // coordinates in spec, then advance status to VMManaged, then ACK. + // + // A spec patch increments metadata.generation, so observedGeneration must be + // taken from the post-patch generation (returned by PatchCsiVolumeInfo) rather + // than the value read before the patch; otherwise vm-operator's green-signal + // check would never be satisfied. + gen := cvi.Generation + if rec.BackingDiskPath != "" || rec.DiskUUID != "" { + specPatch := map[string]interface{}{ + "spec": map[string]interface{}{ + "diskPath": rec.BackingDiskPath, + "diskUUID": rec.DiskUUID, + }, + } + patchBytes, marshalErr := json.Marshal(specPatch) + if marshalErr != nil { + return fmt.Errorf("recoverOneRecord: failed to marshal spec patch for volume %q: %w", + rec.VolumeID, marshalErr) + } + patchedGen, patchErr := cviSvc.PatchCsiVolumeInfo(ctx, rec.VolumeID, patchBytes) + if patchErr != nil { + return fmt.Errorf("recoverOneRecord: failed to patch spec for volume %q: %w", + rec.VolumeID, patchErr) + } + gen = patchedGen + log.Infof("recoverOneRecord: persisted diskPath and diskUUID in spec for volume %q (generation=%d)", + rec.VolumeID, gen) + } + + // Advance status to VMManaged, mirroring the steady-state unregister status + // shape (ownership, phase, observedGeneration, cleared error, Ready condition). + statusPatch := map[string]interface{}{ + "status": map[string]interface{}{ + "ownership": string(csivolumeinfov1alpha1.OwnershipStateVMManaged), + "phase": string(csivolumeinfov1alpha1.PhaseSucceeded), + "observedGeneration": gen, + "error": "", + "conditions": []interface{}{ + map[string]interface{}{ + "type": conditionTypeReady, + "status": string(metav1.ConditionTrue), + "reason": reasonUnregisterSucceeded, + "lastTransitionTime": metav1.Now().UTC().Format(time.RFC3339), + }, + }, + }, + } + statusBytes, marshalErr := json.Marshal(statusPatch) + if marshalErr != nil { + return fmt.Errorf("recoverOneRecord: failed to marshal status patch for volume %q: %w", + rec.VolumeID, marshalErr) + } + if patchErr := cviSvc.PatchCsiVolumeInfoStatus(ctx, rec.VolumeID, statusBytes); patchErr != nil { + return fmt.Errorf("recoverOneRecord: failed to patch status for volume %q: %w", + rec.VolumeID, patchErr) + } + log.Infof("recoverOneRecord: advanced CsiVolumeInfo for volume %q to VMManaged", rec.VolumeID) + + // Send the acknowledgement to delete the PENDING_UNREGISTER row. + if ackErr := volumeManager.AckUnregister(ctx, rec.VolumeID); ackErr != nil { + return fmt.Errorf("recoverOneRecord: AckUnregister failed for volume %q: %w", + rec.VolumeID, ackErr) + } + log.Infof("recoverOneRecord: completed recovery for volume %q", rec.VolumeID) + return nil +} diff --git a/pkg/apis/cnsoperator/csivolumeinfo/pending_unregister_recovery_test.go b/pkg/apis/cnsoperator/csivolumeinfo/pending_unregister_recovery_test.go new file mode 100644 index 0000000000..3c14157ecd --- /dev/null +++ b/pkg/apis/cnsoperator/csivolumeinfo/pending_unregister_recovery_test.go @@ -0,0 +1,171 @@ +/* +Copyright 2026 The Kubernetes Authors. + +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 csivolumeinfo_test + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + csivolumeinfo "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/csivolumeinfo" + csivolumeinfov1alpha1 "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/csivolumeinfo/v1alpha1" + cnsvolumes "sigs.k8s.io/vsphere-csi-driver/v3/pkg/common/cns-lib/volume" + "sigs.k8s.io/vsphere-csi-driver/v3/pkg/common/unittestcommon" +) + +// recoveryVolumeManager embeds the shared mock volume manager and overrides only +// the two-phase unregister query/ack methods exercised by recovery. +type recoveryVolumeManager struct { + *unittestcommon.MockVolumeManager + pendingRecords []cnsvolumes.PendingUnregisterRecord + queryErr error + ackedIDs []string +} + +func (r *recoveryVolumeManager) QueryPendingUnregisters(ctx context.Context) ( + []cnsvolumes.PendingUnregisterRecord, error) { + if r.queryErr != nil { + return nil, r.queryErr + } + return r.pendingRecords, nil +} + +func (r *recoveryVolumeManager) AckUnregister(ctx context.Context, volumeID string) error { + r.ackedIDs = append(r.ackedIDs, volumeID) + return nil +} + +func (r *recoveryVolumeManager) GetDiskFolderURL(ctx context.Context, datastorePath string) (string, error) { + return "", nil +} + +func newRecoveryMgr() *recoveryVolumeManager { + return &recoveryVolumeManager{MockVolumeManager: &unittestcommon.MockVolumeManager{}} +} + +func newRecoveryCVISvc(t *testing.T, cvis ...*csivolumeinfov1alpha1.CsiVolumeInfo) csivolumeinfo.CsiVolumeInfoService { + t.Helper() + s := newScheme(t) + clientObjs := make([]client.Object, len(cvis)) + for i, c := range cvis { + clientObjs[i] = c + } + fakeClient := fake.NewClientBuilder(). + WithScheme(s). + WithObjects(clientObjs...). + WithStatusSubresource(&csivolumeinfov1alpha1.CsiVolumeInfo{}). + Build() + return csivolumeinfo.NewCsiVolumeInfoService(fakeClient) +} + +// buildSimpleCVI creates a minimal CsiVolumeInfo with the given volumeID and ownership. +func buildSimpleCVI(volumeID string, + ownership csivolumeinfov1alpha1.OwnershipState) *csivolumeinfov1alpha1.CsiVolumeInfo { + cvi := &csivolumeinfov1alpha1.CsiVolumeInfo{ + ObjectMeta: metav1.ObjectMeta{ + Name: csivolumeinfov1alpha1.CVINamePrefix + volumeID, + Namespace: csivolumeinfov1alpha1.CVINamespace, + }, + Spec: csivolumeinfov1alpha1.CsiVolumeInfoSpec{ + VolumeID: volumeID, + }, + } + cvi.Status.Ownership = ownership + return cvi +} + +func TestRecoverPendingUnregisters_NoPendingRecords(t *testing.T) { + mgr := newRecoveryMgr() + svc := newRecoveryCVISvc(t) + require.NoError(t, csivolumeinfo.RecoverPendingUnregisters(context.Background(), mgr, svc)) + require.Empty(t, mgr.ackedIDs) +} + +func TestRecoverPendingUnregisters_QueryError(t *testing.T) { + mgr := newRecoveryMgr() + mgr.queryErr = errors.New("CNS unavailable") + svc := newRecoveryCVISvc(t) + require.ErrorContains(t, csivolumeinfo.RecoverPendingUnregisters(context.Background(), mgr, svc), + "QueryPendingUnregisters") +} + +func TestRecoverPendingUnregisters_OrphanRecord_Acked(t *testing.T) { + mgr := newRecoveryMgr() + mgr.pendingRecords = []cnsvolumes.PendingUnregisterRecord{ + {VolumeID: "vol-orphan", BackingDiskPath: "/ds/orphan.vmdk", DiskUUID: "uuid-1"}, + } + svc := newRecoveryCVISvc(t) // no CVI stored + + require.NoError(t, csivolumeinfo.RecoverPendingUnregisters(context.Background(), mgr, svc)) + require.Equal(t, []string{"vol-orphan"}, mgr.ackedIDs) +} + +func TestRecoverPendingUnregisters_AlreadyVMManaged_DoubleACK(t *testing.T) { + cvi := buildSimpleCVI("vol-done", csivolumeinfov1alpha1.OwnershipStateVMManaged) + mgr := newRecoveryMgr() + mgr.pendingRecords = []cnsvolumes.PendingUnregisterRecord{ + {VolumeID: "vol-done", BackingDiskPath: "/ds/done.vmdk", DiskUUID: "uuid-done"}, + } + svc := newRecoveryCVISvc(t, cvi) + + require.NoError(t, csivolumeinfo.RecoverPendingUnregisters(context.Background(), mgr, svc)) + // Double-ACK: no spec/status patches needed. + require.Equal(t, []string{"vol-done"}, mgr.ackedIDs) +} + +func TestRecoverPendingUnregisters_CSIManaged_PatchesAndACKs(t *testing.T) { + cvi := buildSimpleCVI("vol-csi", csivolumeinfov1alpha1.OwnershipStateCSIManaged) + mgr := newRecoveryMgr() + mgr.pendingRecords = []cnsvolumes.PendingUnregisterRecord{ + {VolumeID: "vol-csi", BackingDiskPath: "/ds/csi.vmdk", DiskUUID: "uuid-csi"}, + } + svc := newRecoveryCVISvc(t, cvi) + + require.NoError(t, csivolumeinfo.RecoverPendingUnregisters(context.Background(), mgr, svc)) + require.Equal(t, []string{"vol-csi"}, mgr.ackedIDs) +} + +func TestRecoverPendingUnregisters_BlankOwnership_PatchesAndACKs(t *testing.T) { + cvi := buildSimpleCVI("vol-blank", "") // empty = treat as CSIManaged + mgr := newRecoveryMgr() + mgr.pendingRecords = []cnsvolumes.PendingUnregisterRecord{ + {VolumeID: "vol-blank", BackingDiskPath: "/ds/blank.vmdk", DiskUUID: "uuid-blank"}, + } + svc := newRecoveryCVISvc(t, cvi) + + require.NoError(t, csivolumeinfo.RecoverPendingUnregisters(context.Background(), mgr, svc)) + require.Equal(t, []string{"vol-blank"}, mgr.ackedIDs) +} + +func TestRecoverPendingUnregisters_OneBadRecordDoesNotAbortBatch(t *testing.T) { + goodCVI := buildSimpleCVI("vol-good", csivolumeinfov1alpha1.OwnershipStateCSIManaged) + mgr := newRecoveryMgr() + mgr.pendingRecords = []cnsvolumes.PendingUnregisterRecord{ + {VolumeID: "vol-missing"}, // no CVI → will ACK orphan + {VolumeID: "vol-good", BackingDiskPath: "/ds/good.vmdk", DiskUUID: "uuid-good"}, + } + svc := newRecoveryCVISvc(t, goodCVI) + + // Both records should be processed; vol-missing will be acked as orphan. + require.NoError(t, csivolumeinfo.RecoverPendingUnregisters(context.Background(), mgr, svc)) + require.Len(t, mgr.ackedIDs, 2) +} diff --git a/pkg/apis/cnsoperator/csivolumeinfo/v1alpha1/csivolumeinfo_types.go b/pkg/apis/cnsoperator/csivolumeinfo/v1alpha1/csivolumeinfo_types.go new file mode 100644 index 0000000000..08e5f9797b --- /dev/null +++ b/pkg/apis/cnsoperator/csivolumeinfo/v1alpha1/csivolumeinfo_types.go @@ -0,0 +1,177 @@ +/* +Copyright 2026 The Kubernetes Authors. + +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" +) + +const ( + // CRDSingular represents the singular name of CsiVolumeInfo CRD. + CRDSingular = "csivolumeinfo" + // CRDPlural represents the plural name of CsiVolumeInfo CRD. + CRDPlural = "csivolumeinfos" + + // VolumeProtectionFinalizer prevents GC while ownership is VMManaged. + VolumeProtectionFinalizer = "csi.vsphere.vmware.com/volume-protection" + + // CVINamespace is the namespace where all CsiVolumeInfo CRs live. + CVINamespace = "vmware-system-csi" + + // CVINamePrefix is prepended to the volumeID to form the CR name. + CVINamePrefix = "cns-volume-" +) + +// OwnershipState is the current ownership of the volume. +// +kubebuilder:validation:Enum=CSIManaged;VMManaged +type OwnershipState string + +const ( + // OwnershipStateCSIManaged is the steady state when the volume is a + // registered FCD managed by CSI. + OwnershipStateCSIManaged OwnershipState = "CSIManaged" + + // OwnershipStateVMManaged is the steady state when the disk is a plain + // VMDK managed by a greenfield VM. + OwnershipStateVMManaged OwnershipState = "VMManaged" +) + +// PhaseState represents the reconcile phase of a CsiVolumeInfo. +// +kubebuilder:validation:Enum=Pending;Succeeded;Failed +type PhaseState string + +const ( + // PhasePending indicates the controller has not yet acted on the current spec generation. + PhasePending PhaseState = "Pending" + + // PhaseSucceeded indicates the last reconcile completed successfully. + PhaseSucceeded PhaseState = "Succeeded" + + // PhaseFailed indicates the last reconcile encountered an error. + PhaseFailed PhaseState = "Failed" +) + +// VirtualMachineRef identifies a VM attached to the volume. +type VirtualMachineRef struct { + // VMName is the VirtualMachine CR name. + VMName string `json:"vmName"` + // VMInstanceUUID is the instance UUID of the VM. + // +optional + VMInstanceUUID string `json:"vmInstanceUUID,omitempty"` +} + +// CsiVolumeInfoSpec defines the desired state of CsiVolumeInfo. +type CsiVolumeInfoSpec struct { + // VolumeID is the CNS volume ID / PV volumeHandle. Immutable after creation. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + VolumeID string `json:"volumeID"` + + // PVCName is the name of the bound PVC. Updated in place on Retain-reclaim rebind. + // +kubebuilder:validation:Required + PVCName string `json:"pvcName"` + + // PVCNamespace is the namespace of the bound PVC. + // +kubebuilder:validation:Required + PVCNamespace string `json:"pvcNamespace"` + + // PVName is the name of the bound PersistentVolume. + // +kubebuilder:validation:Required + PVName string `json:"pvName"` + + // DiskUUID is the stable identifier of the virtual disk. Written by CSI after + // UnregisterVolumeEx completes and remains set for the lifetime of the VM-managed + // phase. Cleared when re-registered as FCD. + // +optional + DiskUUID string `json:"diskUUID,omitempty"` + + // DiskPath is the datastore path to the VMDK file. Written by CSI alongside DiskUUID + // after UnregisterVolumeEx. May be refreshed JIT by vm-operator before attachment. + // +optional + DiskPath string `json:"diskPath,omitempty"` + + // VMs lists the VirtualMachine CRs that have this volume attached. + // An empty slice means no VM currently owns the disk (CSI-managed steady state). + // vm-operator is the sole writer of this field. + // +optional + // +listType=map + // +listMapKey=vmName + VMs []VirtualMachineRef `json:"vms,omitempty"` +} + +// CsiVolumeInfoStatus defines the observed state of CsiVolumeInfo. +// Written exclusively via the /status subresource by the CsiVolumeInfo controller. +type CsiVolumeInfoStatus struct { + // Ownership is the current ownership state of the volume. + // +optional + Ownership OwnershipState `json:"ownership,omitempty"` + + // Phase reflects the last reconcile outcome. + // +optional + Phase PhaseState `json:"phase,omitempty"` + + // ObservedGeneration is the spec.generation last acted on by the controller. + // vm-operator waits until observedGeneration >= spec.generation before proceeding. + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + + // Error holds the last reconcile error message, if any. + // +optional + Error string `json:"error,omitempty"` + + // Conditions is a standard K8s condition array. + // +optional + // +listType=map + // +listMapKey=type + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:shortName=cvi,scope=Namespaced +// +kubebuilder:printcolumn:name="Ownership",type=string,JSONPath=`.status.ownership` +// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` + +// CsiVolumeInfo tracks the ownership lifecycle of a CSI volume for the VM-owned +// volume attach/detach model. One CR exists per PV in the vmware-system-csi namespace. +type CsiVolumeInfo struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec CsiVolumeInfoSpec `json:"spec"` + Status CsiVolumeInfoStatus `json:"status,omitempty"` +} + +// GetConditions returns the conditions for controller-runtime conditions interface. +func (in *CsiVolumeInfo) GetConditions() []metav1.Condition { + return in.Status.Conditions +} + +// SetConditions sets the conditions for controller-runtime conditions interface. +func (in *CsiVolumeInfo) SetConditions(conditions []metav1.Condition) { + in.Status.Conditions = conditions +} + +// +kubebuilder:object:root=true + +// CsiVolumeInfoList contains a list of CsiVolumeInfo. +type CsiVolumeInfoList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []CsiVolumeInfo `json:"items"` +} diff --git a/pkg/apis/cnsoperator/csivolumeinfo/v1alpha1/doc.go b/pkg/apis/cnsoperator/csivolumeinfo/v1alpha1/doc.go new file mode 100644 index 0000000000..cbeb1f96c7 --- /dev/null +++ b/pkg/apis/cnsoperator/csivolumeinfo/v1alpha1/doc.go @@ -0,0 +1,5 @@ +// +k8s:deepcopy-gen=package +// +k8s:defaulter-gen=TypeMeta +// +groupName=cns.vmware.com + +package v1alpha1 diff --git a/pkg/apis/cnsoperator/csivolumeinfo/v1alpha1/register.go b/pkg/apis/cnsoperator/csivolumeinfo/v1alpha1/register.go new file mode 100644 index 0000000000..7711366b05 --- /dev/null +++ b/pkg/apis/cnsoperator/csivolumeinfo/v1alpha1/register.go @@ -0,0 +1,66 @@ +/* +Copyright 2026 The Kubernetes Authors. + +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" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// GroupName represents the group for csivolumeinfo APIs. +const GroupName = "cns.vmware.com" + +// Version represents the version for csivolumeinfo APIs. +const Version = "v1alpha1" + +// SchemeGroupVersion defines the schema Group and Version for csivolumeinfo. +var SchemeGroupVersion = schema.GroupVersion{ + Group: GroupName, + Version: Version, +} + +var ( + schemeBuilder runtime.SchemeBuilder + localSchemeBuilder = &schemeBuilder + // AddToScheme adds all csivolumeinfo types to the given scheme. + AddToScheme = localSchemeBuilder.AddToScheme +) + +func init() { + // We only register manually written functions here. The registration of the + // generated functions takes place in the generated files. The separation + // makes the code compile even when the generated files are missing. + localSchemeBuilder.Register(addKnownTypes) +} + +// Resource takes an unqualified resource and returns a Group-qualified GroupResource. +func Resource(resource string) schema.GroupResource { + return SchemeGroupVersion.WithResource(resource).GroupResource() +} + +// addKnownTypes adds the list of known types to the given scheme. +func addKnownTypes(scheme *runtime.Scheme) error { + scheme.AddKnownTypes( + SchemeGroupVersion, + &CsiVolumeInfo{}, + &CsiVolumeInfoList{}, + ) + scheme.AddKnownTypes(SchemeGroupVersion, &metav1.Status{}) + metav1.AddToGroupVersion(scheme, SchemeGroupVersion) + return nil +} diff --git a/pkg/apis/cnsoperator/csivolumeinfo/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/cnsoperator/csivolumeinfo/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 0000000000..574466f2a6 --- /dev/null +++ b/pkg/apis/cnsoperator/csivolumeinfo/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,142 @@ +//go:build !ignore_autogenerated + +/* +Copyright 2026 The Kubernetes Authors. + +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 ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto copies all properties of this into another CsiVolumeInfo instance. +func (in *CsiVolumeInfo) DeepCopyInto(out *CsiVolumeInfo) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy returns a deep copy of CsiVolumeInfo. +func (in *CsiVolumeInfo) DeepCopy() *CsiVolumeInfo { + if in == nil { + return nil + } + out := new(CsiVolumeInfo) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject implements runtime.Object. +func (in *CsiVolumeInfo) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto copies all properties of this into another CsiVolumeInfoList instance. +func (in *CsiVolumeInfoList) DeepCopyInto(out *CsiVolumeInfoList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]CsiVolumeInfo, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy returns a deep copy of CsiVolumeInfoList. +func (in *CsiVolumeInfoList) DeepCopy() *CsiVolumeInfoList { + if in == nil { + return nil + } + out := new(CsiVolumeInfoList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject implements runtime.Object. +func (in *CsiVolumeInfoList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto copies all properties of this into another CsiVolumeInfoSpec. +func (in *CsiVolumeInfoSpec) DeepCopyInto(out *CsiVolumeInfoSpec) { + *out = *in + if in.VMs != nil { + in, out := &in.VMs, &out.VMs + *out = make([]VirtualMachineRef, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy returns a deep copy of CsiVolumeInfoSpec. +func (in *CsiVolumeInfoSpec) DeepCopy() *CsiVolumeInfoSpec { + if in == nil { + return nil + } + out := new(CsiVolumeInfoSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto copies all properties of this into another CsiVolumeInfoStatus. +func (in *CsiVolumeInfoStatus) DeepCopyInto(out *CsiVolumeInfoStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]metav1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy returns a deep copy of CsiVolumeInfoStatus. +func (in *CsiVolumeInfoStatus) DeepCopy() *CsiVolumeInfoStatus { + if in == nil { + return nil + } + out := new(CsiVolumeInfoStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto copies all properties of this into another VirtualMachineRef. +func (in *VirtualMachineRef) DeepCopyInto(out *VirtualMachineRef) { + *out = *in +} + +// DeepCopy returns a deep copy of VirtualMachineRef. +func (in *VirtualMachineRef) DeepCopy() *VirtualMachineRef { + if in == nil { + return nil + } + out := new(VirtualMachineRef) + in.DeepCopyInto(out) + return out +} diff --git a/pkg/apis/cnsoperator/register.go b/pkg/apis/cnsoperator/register.go index aef5cbee67..a47bbc5ba6 100644 --- a/pkg/apis/cnsoperator/register.go +++ b/pkg/apis/cnsoperator/register.go @@ -32,6 +32,7 @@ import ( cnsregistervolumev1alpha1 "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/cnsregistervolume/v1alpha1" cnsunregistervolumev1alpha1 "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/cnsunregistervolume/v1alpha1" cnsvolumemetadatav1alpha1 "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/cnsvolumemetadata/v1alpha1" + csivolumeinfov1alpha1 "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/csivolumeinfo/v1alpha1" infrastoragepolicyinfov1alpha1 "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/infrastoragepolicyinfo/v1alpha1" storagepolicyv1alpha1 "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/storagepolicy/v1alpha1" storagepolicyv1alpha2 "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/storagepolicy/v1alpha2" @@ -95,6 +96,10 @@ var ( StoragePolicyInfoSingular = "storagepolicyinfo" // StoragePolicyInfoPlural is plural of StoragePolicyInfo StoragePolicyInfoPlural = "storagepolicyinfos" + // CsiVolumeInfoSingular is the singular name of CsiVolumeInfo. + CsiVolumeInfoSingular = "csivolumeinfo" + // CsiVolumeInfoPlural is the plural name of CsiVolumeInfo. + CsiVolumeInfoPlural = "csivolumeinfos" ) var ( @@ -219,6 +224,12 @@ func addKnownTypes(scheme *runtime.Scheme) error { &storagepolicyinfov1alpha1.StoragePolicyInfoList{}, ) + scheme.AddKnownTypes( + SchemeGroupVersion, + &csivolumeinfov1alpha1.CsiVolumeInfo{}, + &csivolumeinfov1alpha1.CsiVolumeInfoList{}, + ) + scheme.AddKnownTypes( SchemeGroupVersion, &metav1.Status{}, diff --git a/pkg/common/cns-lib/volume/manager.go b/pkg/common/cns-lib/volume/manager.go index 1c3a5afe94..93d2981c1c 100644 --- a/pkg/common/cns-lib/volume/manager.go +++ b/pkg/common/cns-lib/volume/manager.go @@ -27,6 +27,7 @@ import ( "github.com/davecgh/go-spew/spew" "github.com/vmware/govmomi/cns" cnstypes "github.com/vmware/govmomi/cns/types" + "github.com/vmware/govmomi/find" "github.com/vmware/govmomi/object" "github.com/vmware/govmomi/vim25" "github.com/vmware/govmomi/vim25/soap" @@ -79,6 +80,14 @@ const ( MbInBytes = int64(1024 * 1024) ) +// PendingUnregisterRecord holds data for a volume in PENDING_UNREGISTER state. +// Each record represents an unregister started but not yet acknowledged. +type PendingUnregisterRecord struct { + VolumeID string + BackingDiskPath string + DiskUUID string +} + // Manager provides functionality to manage volumes. type Manager interface { // CreateVolume creates a new volume given its spec. @@ -180,6 +189,22 @@ type Manager interface { // QueryFCDChangedBlocks returns changed block ranges using FCD VSLM QueryChangedDiskAreas with baseChangeID. QueryFCDChangedBlocks(ctx context.Context, volumeID, targetSnapshotID, baseChangeID string, startingOffset uint64) ([]DiskArea, uint64, error) + // UnregisterVolumeEx initiates phase-1 of the two-phase CNS unregister protocol. + // It unregisters the FCD from CNS and returns the backing-disk path and disk UUID + // that the caller must persist before invoking AckUnregister. The CNS record + // transitions to PENDING_UNREGISTER until AckUnregister is called. + UnregisterVolumeEx(ctx context.Context, volumeID string) (string, string, error) + // QueryPendingUnregisters returns all volumes that are in the PENDING_UNREGISTER + // state in CNS. Used on controller restart to recover any volumes whose AckUnregister + // was never delivered. + QueryPendingUnregisters(ctx context.Context) ([]PendingUnregisterRecord, error) + // AckUnregister completes phase-2 of the two-phase CNS unregister protocol by + // deleting the PENDING_UNREGISTER record from the CNS database. The call is idempotent. + AckUnregister(ctx context.Context, volumeID string) error + // GetDiskFolderURL converts a datastore path in "[dsName] relPath" format to + // the HTTP folder URL format (https://host/folder/relPath?dcPath=...&dsName=...) + // required by CNS's RegisterVMDKWithUrlAction for re-registration after UnregisterVolumeEx. + GetDiskFolderURL(ctx context.Context, datastorePath string) (string, error) } // CnsVolumeInfo hold information related to volume created by CNS. @@ -4491,3 +4516,164 @@ func TranslateVslmError(ctx context.Context, err error) error { return logger.LogNewErrorCodef(log, codes.Internal, "failed with error: %v", err) } + +// UnregisterVolumeEx initiates phase-1 of the two-phase CNS unregister protocol. +// It unregisters the FCD from CNS and returns the backing-disk path and disk UUID. +// The CNS record transitions to PENDING_UNREGISTER; the caller must persist these +// values and then call AckUnregister to complete the handshake. +func (m *defaultManager) UnregisterVolumeEx(ctx context.Context, volumeID string) (string, string, error) { + log := logger.GetLogger(ctx).With("volumeID", volumeID) + log.Infof("UnregisterVolumeEx: entry volumeID=%q", volumeID) + + if m.virtualCenter == nil { + return "", "", errors.New("virtual center connection not established") + } + if err := m.virtualCenter.ConnectCns(ctx); err != nil { + log.Errorf("UnregisterVolumeEx: ConnectCns failed for volumeID=%q: %v", volumeID, err) + return "", "", fmt.Errorf("connecting to CNS failed: %w", err) + } + + spec := []cnstypes.CnsUnregisterVolumeSpec{ + { + VolumeId: cnstypes.CnsVolumeId{Id: volumeID}, + TargetVolumeType: string(cnstypes.CnsUnregisterTargetVolumeTypeFCD), + }, + } + task, err := m.virtualCenter.CnsClient.UnregisterVolumeEx(ctx, spec) + if err != nil { + log.Errorf("UnregisterVolumeEx: CNS call failed for volumeID=%q: %v", volumeID, err) + return "", "", fmt.Errorf("CnsUnregisterVolumeEx failed: %w", err) + } + + taskInfo, err := m.waitOnTask(ctx, task.Reference()) + if err != nil { + log.Errorf("UnregisterVolumeEx: waitOnTask failed for volumeID=%q: %v", volumeID, err) + return "", "", fmt.Errorf("waiting for CnsUnregisterVolumeEx task failed: %w", err) + } + if taskInfo == nil { + log.Errorf("UnregisterVolumeEx: taskInfo is nil for volumeID=%q", volumeID) + return "", "", errors.New("taskInfo is nil for CnsUnregisterVolumeEx task") + } + + res, err := getTaskResultFromTaskInfo(ctx, taskInfo) + if err != nil { + log.Errorf("UnregisterVolumeEx: failed to get task result for volumeID=%q: %v", volumeID, err) + return "", "", fmt.Errorf("failed to get CnsUnregisterVolumeEx task result: %w", err) + } + if res == nil { + log.Errorf("UnregisterVolumeEx: task result is nil for volumeID=%q opId=%q", + volumeID, taskInfo.ActivationId) + return "", "", errors.New("task result is nil for CnsUnregisterVolumeEx task") + } + + unregRes, ok := res.(*cnstypes.CnsUnregisterVolumeResult) + if !ok { + log.Errorf("UnregisterVolumeEx: unexpected result type %T for volumeID=%q", res, volumeID) + return "", "", fmt.Errorf("unexpected task result type %T for CnsUnregisterVolumeEx", res) + } + + volOpRes := unregRes.GetCnsVolumeOperationResult() + if volOpRes.Fault != nil { + fault := ExtractFaultTypeFromVolumeResponseResult(ctx, volOpRes) + log.Errorf("UnregisterVolumeEx: fault for volumeID=%q fault=%q opId=%q", + volumeID, fault, taskInfo.ActivationId) + return "", "", fmt.Errorf("CnsUnregisterVolumeEx fault: %s", fault) + } + + log.Infof("UnregisterVolumeEx: exit volumeID=%q backingDiskPath=%q diskUUID=%q opId=%q", + volumeID, unregRes.BackingDiskPath, unregRes.DiskUUID, taskInfo.ActivationId) + return unregRes.BackingDiskPath, unregRes.DiskUUID, nil +} + +// QueryPendingUnregisters returns all volumes in PENDING_UNREGISTER state from CNS. +// Used on controller restart to recover volumes whose AckUnregister was never delivered. +func (m *defaultManager) QueryPendingUnregisters(ctx context.Context) ([]PendingUnregisterRecord, error) { + log := logger.GetLogger(ctx) + log.Infof("QueryPendingUnregisters: entry") + + if m.virtualCenter == nil { + return nil, errors.New("virtual center connection not established") + } + if err := m.virtualCenter.ConnectCns(ctx); err != nil { + log.Errorf("QueryPendingUnregisters: ConnectCns failed: %v", err) + return nil, fmt.Errorf("connecting to CNS failed: %w", err) + } + + results, err := m.virtualCenter.CnsClient.QueryPendingUnregisters(ctx) + if err != nil { + log.Errorf("QueryPendingUnregisters: CNS call failed: %v", err) + return nil, fmt.Errorf("CnsQueryPendingUnregisters failed: %w", err) + } + + records := make([]PendingUnregisterRecord, 0, len(results)) + for _, r := range results { + records = append(records, PendingUnregisterRecord{ + VolumeID: r.VolumeId.Id, + BackingDiskPath: r.BackingDiskPath, + DiskUUID: r.DiskUUID, + }) + } + log.Infof("QueryPendingUnregisters: exit count=%d", len(records)) + return records, nil +} + +// AckUnregister completes phase-2 of the two-phase CNS unregister protocol by +// deleting the PENDING_UNREGISTER record for the given volumeID. The call is idempotent. +func (m *defaultManager) AckUnregister(ctx context.Context, volumeID string) error { + log := logger.GetLogger(ctx).With("volumeID", volumeID) + log.Infof("AckUnregister: entry volumeID=%q", volumeID) + + if m.virtualCenter == nil { + return errors.New("virtual center connection not established") + } + if err := m.virtualCenter.ConnectCns(ctx); err != nil { + log.Errorf("AckUnregister: ConnectCns failed for volumeID=%q: %v", volumeID, err) + return fmt.Errorf("connecting to CNS failed: %w", err) + } + + volumeIds := []cnstypes.CnsVolumeId{{Id: volumeID}} + if err := m.virtualCenter.CnsClient.AcknowledgeUnregister(ctx, volumeIds); err != nil { + log.Errorf("AckUnregister: CNS call failed for volumeID=%q: %v", volumeID, err) + return fmt.Errorf("CnsAcknowledgeUnregister failed: %w", err) + } + + log.Infof("AckUnregister: exit volumeID=%q acknowledged successfully", volumeID) + return nil +} + +// GetDiskFolderURL converts a datastore path ("[dsName] relPath") to the HTTP +// folder URL format (https://host/folder/relPath?dcPath=...&dsName=...) required +// by CNS's RegisterVMDKWithUrlAction when re-registering a VMDK after UnregisterVolumeEx. +func (m *defaultManager) GetDiskFolderURL(ctx context.Context, datastorePath string) (string, error) { + log := logger.GetLogger(ctx).With("datastorePath", datastorePath) + + var dsPath object.DatastorePath + if !dsPath.FromString(datastorePath) { + return "", fmt.Errorf("GetDiskFolderURL: failed to parse datastore path %q", datastorePath) + } + + if m.virtualCenter == nil { + return "", fmt.Errorf("GetDiskFolderURL: virtual center connection not established") + } + + datacenters, err := m.virtualCenter.GetDatacenters(ctx) + if err != nil { + return "", fmt.Errorf("GetDiskFolderURL: failed to get datacenters: %w", err) + } + + for _, dc := range datacenters { + f := find.NewFinder(m.virtualCenter.Client.Client, false) + f.SetDatacenter(dc.Datacenter) + ds, err := f.Datastore(ctx, dsPath.Datastore) + if err != nil { + log.Debugf("GetDiskFolderURL: datastore %q not found in datacenter %q: %v", + dsPath.Datastore, dc.InventoryPath, err) + continue + } + url := ds.NewURL(dsPath.Path).String() + log.Infof("GetDiskFolderURL: resolved %q → %q (dc=%q)", datastorePath, url, dc.InventoryPath) + return url, nil + } + + return "", fmt.Errorf("GetDiskFolderURL: datastore %q not found in any datacenter", dsPath.Datastore) +} diff --git a/pkg/common/cns-lib/volume/manager_mock.go b/pkg/common/cns-lib/volume/manager_mock.go index 2aacf0c25d..fa48323bc1 100644 --- a/pkg/common/cns-lib/volume/manager_mock.go +++ b/pkg/common/cns-lib/volume/manager_mock.go @@ -235,3 +235,34 @@ func (m MockManager) QueryFCDChangedBlocks(ctx context.Context, //TODO implement me panic("implement me") } + +// UnregisterVolumeEx is the mock implementation of the two-phase CNS unregister phase-1. +// Returns the configured error on failure, otherwise empty backing-disk path and disk UUID. +func (m MockManager) UnregisterVolumeEx(ctx context.Context, volumeID string) (string, string, error) { + if m.failRequest { + return "", "", m.err + } + return "", "", nil +} + +// QueryPendingUnregisters is the mock implementation that returns an empty list on success. +func (m MockManager) QueryPendingUnregisters(ctx context.Context) ([]PendingUnregisterRecord, error) { + if m.failRequest { + return nil, m.err + } + return nil, nil +} + +// AckUnregister is the mock implementation of the two-phase CNS unregister phase-2. +// Returns the configured error on failure, otherwise nil. +func (m MockManager) AckUnregister(ctx context.Context, volumeID string) error { + if m.failRequest { + return m.err + } + return nil +} + +// GetDiskFolderURL is the mock implementation; always returns an empty string. +func (m MockManager) GetDiskFolderURL(ctx context.Context, datastorePath string) (string, error) { + return "", nil +} diff --git a/pkg/common/cns-lib/volume/manager_unreg_test.go b/pkg/common/cns-lib/volume/manager_unreg_test.go new file mode 100644 index 0000000000..b90dff5877 --- /dev/null +++ b/pkg/common/cns-lib/volume/manager_unreg_test.go @@ -0,0 +1,153 @@ +/* +Copyright 2026 The Kubernetes Authors. + +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 volume + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + cnstypes "github.com/vmware/govmomi/cns/types" +) + +// TestMockManagerImplementsInterface verifies that MockManager satisfies the +// full Manager interface (compilation-level check augmented with a runtime +// nil-interface assignment). +func TestMockManagerImplementsInterface(t *testing.T) { + var _ Manager = MockManager{} +} + +// TestMockUnregisterVolumeExSuccess verifies the success path of the mock. +func TestMockUnregisterVolumeExSuccess(t *testing.T) { + ctx := context.Background() + m := NewMockManager(false, nil, "") + + backingDiskPath, diskUUID, err := m.UnregisterVolumeEx(ctx, "test-volume-id") + + require.NoError(t, err) + assert.Empty(t, backingDiskPath) + assert.Empty(t, diskUUID) +} + +// TestMockUnregisterVolumeExFailure verifies the error path of the mock. +func TestMockUnregisterVolumeExFailure(t *testing.T) { + ctx := context.Background() + sentinelErr := errors.New("cns unregister ex failed") + m := NewMockManager(true, sentinelErr, "vim25:SystemError") + + _, _, err := m.UnregisterVolumeEx(ctx, "test-volume-id") + + require.Error(t, err) + assert.Equal(t, sentinelErr, err) +} + +// TestMockQueryPendingUnregistersSuccess verifies the success path. +func TestMockQueryPendingUnregistersSuccess(t *testing.T) { + ctx := context.Background() + m := NewMockManager(false, nil, "") + + results, err := m.QueryPendingUnregisters(ctx) + + require.NoError(t, err) + assert.Nil(t, results) +} + +// TestMockQueryPendingUnregistersFailure verifies the error path. +func TestMockQueryPendingUnregistersFailure(t *testing.T) { + ctx := context.Background() + sentinelErr := errors.New("cns query pending unregisters failed") + m := NewMockManager(true, sentinelErr, "vim25:SystemError") + + results, err := m.QueryPendingUnregisters(ctx) + + require.Error(t, err) + assert.Equal(t, sentinelErr, err) + assert.Nil(t, results) +} + +// TestMockAckUnregisterSuccess verifies the success path. +func TestMockAckUnregisterSuccess(t *testing.T) { + ctx := context.Background() + m := NewMockManager(false, nil, "") + + err := m.AckUnregister(ctx, "test-volume-id") + + require.NoError(t, err) +} + +// TestMockAckUnregisterFailure verifies the error path. +func TestMockAckUnregisterFailure(t *testing.T) { + ctx := context.Background() + sentinelErr := errors.New("cns ack unregister failed") + m := NewMockManager(true, sentinelErr, "vim25:SystemError") + + err := m.AckUnregister(ctx, "test-volume-id") + + require.Error(t, err) + assert.Equal(t, sentinelErr, err) +} + +// TestDefaultManagerUnregisterVolumeExNilVC verifies that UnregisterVolumeEx +// returns an error immediately when the virtualCenter is nil (no live VC +// required for this unit test path). +func TestDefaultManagerUnregisterVolumeExNilVC(t *testing.T) { + ctx := context.Background() + m := &defaultManager{} // virtualCenter is nil + + _, _, err := m.UnregisterVolumeEx(ctx, "vol-001") + + require.Error(t, err) + assert.Contains(t, err.Error(), "virtual center connection not established") +} + +// TestDefaultManagerQueryPendingUnregistersNilVC verifies that +// QueryPendingUnregisters returns an error when the virtualCenter is nil. +func TestDefaultManagerQueryPendingUnregistersNilVC(t *testing.T) { + ctx := context.Background() + m := &defaultManager{} + + _, err := m.QueryPendingUnregisters(ctx) + + require.Error(t, err) + assert.Contains(t, err.Error(), "virtual center connection not established") +} + +// TestDefaultManagerAckUnregisterNilVC verifies that AckUnregister returns +// an error when the virtualCenter is nil. +func TestDefaultManagerAckUnregisterNilVC(t *testing.T) { + ctx := context.Background() + m := &defaultManager{} + + err := m.AckUnregister(ctx, "vol-001") + + require.Error(t, err) + assert.Contains(t, err.Error(), "virtual center connection not established") +} + +// TestQueryPendingUnregistersResultType verifies that the CnsUnregisterVolumeResult +// type has the expected BackingDiskPath and DiskUUID fields (structural regression +// guard — if the govmomi type changes shape, this test will not compile). +func TestQueryPendingUnregistersResultType(t *testing.T) { + r := cnstypes.CnsUnregisterVolumeResult{ + BackingDiskPath: "/vmfs/volumes/ds1/disk.vmdk", + DiskUUID: "6000c29a-1234-5678-abcd-ef0123456789", + } + assert.Equal(t, "/vmfs/volumes/ds1/disk.vmdk", r.BackingDiskPath) + assert.Equal(t, "6000c29a-1234-5678-abcd-ef0123456789", r.DiskUUID) +} diff --git a/pkg/common/cns-lib/volume/util.go b/pkg/common/cns-lib/volume/util.go index 1297b581bb..cc7cd9981e 100644 --- a/pkg/common/cns-lib/volume/util.go +++ b/pkg/common/cns-lib/volume/util.go @@ -632,6 +632,15 @@ func IsCnsVolumeAlreadyExistsFault(ctx context.Context, faultType string) bool { return faultType == "vim.fault.CnsVolumeAlreadyExistsFault" } +// IsCnsAlreadyRegisteredFault returns true if a given faultType value is +// vim.fault.CnsAlreadyRegisteredFault, which CNS returns when a CreateVolume +// (re-register) targets a backing disk that is already a registered FCD. +func IsCnsAlreadyRegisteredFault(ctx context.Context, faultType string) bool { + log := logger.GetLogger(ctx) + log.Infof("Checking fault type: %q is vim.fault.CnsAlreadyRegisteredFault", faultType) + return faultType == "vim.fault.CnsAlreadyRegisteredFault" +} + // IsCnsNotRegisteredFault checks if the fault is CnsNotRegisteredFault func IsCnsNotRegisteredFault(ctx context.Context, fault *types.LocalizedMethodFault) bool { log := logger.GetLogger(ctx) diff --git a/pkg/common/unittestcommon/types.go b/pkg/common/unittestcommon/types.go index c2805a237f..0ac93b369e 100644 --- a/pkg/common/unittestcommon/types.go +++ b/pkg/common/unittestcommon/types.go @@ -250,3 +250,23 @@ func (m *MockVolumeManager) SyncVolume(ctx context.Context, func (m *MockVolumeManager) ReRegisterVolume(ctx context.Context, volumeID string) error { return nil } + +// UnregisterVolumeEx is a stub implementation of the phase-1 two-phase unregister. +func (m *MockVolumeManager) UnregisterVolumeEx(ctx context.Context, volumeID string) (string, string, error) { + return "", "", nil +} + +// QueryPendingUnregisters is a stub implementation returning an empty list. +func (m *MockVolumeManager) QueryPendingUnregisters(ctx context.Context) ([]cnsvolume.PendingUnregisterRecord, error) { + return nil, nil +} + +// AckUnregister is a stub implementation of the phase-2 two-phase unregister. +func (m *MockVolumeManager) AckUnregister(ctx context.Context, volumeID string) error { + return nil +} + +// GetDiskFolderURL is a stub implementation. +func (m *MockVolumeManager) GetDiskFolderURL(ctx context.Context, datastorePath string) (string, error) { + return "", nil +} diff --git a/pkg/csi/service/common/constants.go b/pkg/csi/service/common/constants.go index 7cc495c8d0..f3010438e4 100644 --- a/pkg/csi/service/common/constants.go +++ b/pkg/csi/service/common/constants.go @@ -663,6 +663,12 @@ const ( // control flag has been cleared due to VM ownerRef presence. This prevents // redundant VSLM calls on syncer restarts or during periodic resyncs. AnnVMDeleteProtectionCleared = "cns.vmware.com/vm-delete-protection-cleared" + + // VMOwnedVolumes is the WCP capability that gates the VM-owned volume + // attach/detach path. When enabled, the CSI driver creates CsiVolumeInfo + // CRs for new PVCs and uses FCD unregister/re-register instead of + // CnsAttachVolume/CnsDetachVolume for greenfield VMs. + VMOwnedVolumes = "supports_vm_owned_volumes" ) var WCPFeatureStates = map[string]struct{}{ @@ -684,6 +690,7 @@ var WCPFeatureStates = map[string]struct{}{ SupportsExposingStoragePolicyAttributes: {}, SupportsPerNamespaceNetworkProviders: {}, VMPVCStoragePolicyMutability: {}, + VMOwnedVolumes: {}, } // WCPFeatureStatesSupportsLateEnablement contains capabilities that can be enabled later @@ -703,6 +710,7 @@ var WCPFeatureStatesSupportsLateEnablement = map[string]struct{}{ SupportsExposingStoragePolicyAttributes: {}, SupportsPerNamespaceNetworkProviders: {}, VMPVCStoragePolicyMutability: {}, + VMOwnedVolumes: {}, } // WCPFeatureAssociatedWithPVCSI contains FSS name used in PVCSI and associated WCP Capability name on a diff --git a/pkg/csi/service/common/util_test.go b/pkg/csi/service/common/util_test.go index 3c911dd566..d45f042127 100644 --- a/pkg/csi/service/common/util_test.go +++ b/pkg/csi/service/common/util_test.go @@ -956,6 +956,20 @@ func (m *cbtFlagsMockVolumeManager) SyncVolume(context.Context, func (m *cbtFlagsMockVolumeManager) ReRegisterVolume(context.Context, string) error { return nil } +func (m *cbtFlagsMockVolumeManager) UnregisterVolumeEx(context.Context, string) (string, string, error) { + return "", "", nil +} +func (m *cbtFlagsMockVolumeManager) QueryPendingUnregisters( + context.Context) ([]cnsvolume.PendingUnregisterRecord, error) { + return nil, nil +} +func (m *cbtFlagsMockVolumeManager) AckUnregister(context.Context, string) error { + return nil +} + +func (m *cbtFlagsMockVolumeManager) GetDiskFolderURL(context.Context, string) (string, error) { + return "", nil +} func (m *cbtFlagsMockVolumeManager) QueryFCDAllocatedBlocks(context.Context, string, string, uint64) ([]cnsvolume.DiskArea, uint64, error) { return nil, 0, nil diff --git a/pkg/csi/service/common/vsphereutil_test.go b/pkg/csi/service/common/vsphereutil_test.go index 1ab21b5b1e..c1f44364f3 100644 --- a/pkg/csi/service/common/vsphereutil_test.go +++ b/pkg/csi/service/common/vsphereutil_test.go @@ -155,6 +155,23 @@ func (m *mockVolumeManager) ReRegisterVolume(ctx context.Context, volumeID strin return nil } +func (m *mockVolumeManager) UnregisterVolumeEx(ctx context.Context, volumeID string) (string, string, error) { + return "", "", nil +} + +func (m *mockVolumeManager) QueryPendingUnregisters(ctx context.Context) ( + []cnsvolume.PendingUnregisterRecord, error) { + return nil, nil +} + +func (m *mockVolumeManager) AckUnregister(ctx context.Context, volumeID string) error { + return nil +} + +func (m *mockVolumeManager) GetDiskFolderURL(ctx context.Context, datastorePath string) (string, error) { + return "", nil +} + func TestQueryVolumeSnapshotsByVolumeIDWithQuerySnapshotsCnsVolumeNotFoundFault(t *testing.T) { // Skip test on ARM64 due to gomonkey limitations if runtime.GOARCH == "arm64" { diff --git a/pkg/csi/service/wcp/controller.go b/pkg/csi/service/wcp/controller.go index 8797553147..0fce6a9290 100644 --- a/pkg/csi/service/wcp/controller.go +++ b/pkg/csi/service/wcp/controller.go @@ -51,6 +51,8 @@ import ( ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" ctrlconfig "sigs.k8s.io/controller-runtime/pkg/client/config" cbtconfigv1alpha1 "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cbtconfig/v1alpha1" + csivolumeinfosvc "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/csivolumeinfo" + csivolumeinfov1alpha1 "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/csivolumeinfo/v1alpha1" fvsapis "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/filevolume" "sigs.k8s.io/vsphere-csi-driver/v3/pkg/common/cns-lib/crypto" cnsvolume "sigs.k8s.io/vsphere-csi-driver/v3/pkg/common/cns-lib/volume" @@ -118,6 +120,13 @@ var ( // vsan-file-service-policy-latebinding storage classes; legacy non-FVS paths do not use this // value. Empty when the per-namespace capability is on (in which case the value is unused). cachedGlobalNetworkProvider string + // isVMOwnedVolumesFSSEnabled is true when the VMOwnedVolumes WCP capability is enabled. + // When true, a CsiVolumeInfo CR is created at provisioning time and the VM-owned + // attach/detach path is used for greenfield VMs. + isVMOwnedVolumesFSSEnabled bool + // csiVolumeInfoService is the service used to create and manage CsiVolumeInfo CRs. + // Initialised once in Init when isVMOwnedVolumesFSSEnabled is true. + csiVolumeInfoService csivolumeinfosvc.CsiVolumeInfoService ) var getCandidateDatastores = cnsvsphere.GetCandidateDatastoresInCluster @@ -429,6 +438,15 @@ func (c *controller) Init(config *cnsconfig.Config, version string) error { } log.Infof("Successfully initialized VolumeInfoService") } + isVMOwnedVolumesFSSEnabled = commonco.ContainerOrchestratorUtility.IsFSSEnabled(ctx, common.VMOwnedVolumes) + if isVMOwnedVolumesFSSEnabled { + log.Info("VMOwnedVolumes capability enabled; initializing CsiVolumeInfo service") + csiVolumeInfoService, err = csivolumeinfosvc.InitCsiVolumeInfoService(ctx) + if err != nil { + return logger.LogNewErrorf(log, "failed to initialize CsiVolumeInfo service. Error: %+v", err) + } + log.Infof("Successfully initialized CsiVolumeInfo service") + } cfgDirPath := filepath.Dir(cfgPath) log.Infof("Adding watch on path: %q", cfgDirPath) @@ -1262,6 +1280,48 @@ func (c *controller) createBlockVolume(ctx context.Context, req *csi.CreateVolum } } + if isVMOwnedVolumesFSSEnabled { + pvName := req.Name + cviPVCName := pvcName + cviPVCNamespace := pvcNamespace + if cviPVCName == "" { + if v, ok := req.Parameters[common.AttributePvcName]; ok { + cviPVCName = v + } + } + if cviPVCNamespace == "" { + if v, ok := req.Parameters[common.AttributePvcNamespace]; ok { + cviPVCNamespace = v + } + } + if cviPVCName == "" || cviPVCNamespace == "" || pvName == "" { + log.Warnf("skipping CsiVolumeInfo creation for volumeID %q: missing pvcName=%q "+ + "pvcNamespace=%q pvName=%q", volumeInfo.VolumeID.Id, cviPVCName, cviPVCNamespace, pvName) + } else { + cvi := &csivolumeinfov1alpha1.CsiVolumeInfo{ + ObjectMeta: metav1.ObjectMeta{ + Name: csivolumeinfosvc.GetCsiVolumeInfoCRName(volumeInfo.VolumeID.Id), + Namespace: csivolumeinfov1alpha1.CVINamespace, + }, + Spec: csivolumeinfov1alpha1.CsiVolumeInfoSpec{ + VolumeID: volumeInfo.VolumeID.Id, + PVCName: cviPVCName, + PVCNamespace: cviPVCNamespace, + PVName: pvName, + }, + } + if createErr := csiVolumeInfoService.CreateCsiVolumeInfo(ctx, cvi); createErr != nil { + // Log but do not fail provisioning; the CsiVolumeInfo reconciler will + // repair missing CRs on the next full-sync cycle. + log.Warnf("failed to create CsiVolumeInfo for volumeID %q: %v; "+ + "the syncer will repair on the next full-sync", volumeInfo.VolumeID.Id, createErr) + } else { + log.Infof("created CsiVolumeInfo CR %q for volumeID %q pvName %q", + cvi.Name, volumeInfo.VolumeID.Id, pvName) + } + } + } + if isCSIBackupAPIEnabled { // It's the best effort scenario to enable the CBT before create volume. // If any error occurs during CBT enablement, it may be deferred to attachment or @@ -2005,6 +2065,19 @@ func (c *controller) DeleteVolume(ctx context.Context, req *csi.DeleteVolumeRequ return nil, faultType, logger.LogNewErrorCodef(log, codes.Internal, "failed to delete volume: %q. Error: %+v", req.VolumeId, err) } + + // After the FCD is deleted, remove the CsiVolumeInfo CR so it does not + // become an orphan. GC via PV ownerReference handles the normal path; + // this explicit delete covers the permanent-removal case where the PV + // is gone before GC runs. NotFound is treated as success (idempotent). + if isVMOwnedVolumesFSSEnabled && csiVolumeInfoService != nil { + if delErr := csiVolumeInfoService.DeleteCsiVolumeInfo(ctx, req.VolumeId); delErr != nil { + log.Warnf("DeleteVolume: failed to delete CsiVolumeInfo for volume %q: %v; "+ + "GC cascade will clean it up on PV deletion", req.VolumeId, delErr) + } else { + log.Infof("DeleteVolume: deleted CsiVolumeInfo for volume %q", req.VolumeId) + } + } return &csi.DeleteVolumeResponse{}, "", nil } resp, faultType, err := deleteVolumeInternal() @@ -2692,6 +2765,17 @@ func (c *controller) CreateSnapshot(ctx context.Context, req *csi.CreateSnapshot } volumeID := req.GetSourceVolumeId() volumeType = prometheus.PrometheusBlockVolumeType + + // Reject snapshot creation when the volume is owned by a VM. Snapshots + // of VM-managed disks are not supported through CSI; vm-operator owns + // that path. + if isVMOwnedVolumesFSSEnabled && csiVolumeInfoService != nil { + if blockErr := rejectSnapshotIfVMManaged(ctx, csiVolumeInfoService, volumeID); blockErr != nil { + return nil, logger.LogNewErrorCodef(log, codes.FailedPrecondition, + "snapshot creation rejected for volume %q: %v", volumeID, blockErr) + } + } + // Query capacity in MB for block volume snapshot volumeIds := []cnstypes.CnsVolumeId{{Id: volumeID}} cnsVolumeDetailsMap, err := utils.QueryVolumeDetailsUtil(ctx, c.manager.VolumeManager, volumeIds) @@ -3263,6 +3347,25 @@ func (c *controller) ControllerModifyVolume(ctx context.Context, req *csi.Contro } } +// rejectSnapshotIfVMManaged returns an error when the CsiVolumeInfo for the +// given volumeID shows that the volume is currently VM-managed. This prevents +// snapshot creation on a disk that is no longer an FCD. +// Returns nil when the CVI is absent or in any non-VMManaged state. +func rejectSnapshotIfVMManaged(ctx context.Context, + cviSvc csivolumeinfosvc.CsiVolumeInfoService, volumeID string) error { + log := logger.GetLogger(ctx) + cvi, err := cviSvc.GetCsiVolumeInfo(ctx, volumeID) + if err != nil { + log.Warnf("rejectSnapshotIfVMManaged: failed to get CsiVolumeInfo for volume %q: %v; "+ + "allowing snapshot (fail-open)", volumeID, err) + return nil + } + if cvi == nil || cvi.Status.Ownership != csivolumeinfov1alpha1.OwnershipStateVMManaged { + return nil + } + return fmt.Errorf("volume %q is currently owned by a VM; snapshot creation is not allowed", volumeID) +} + func (c *controller) UpdateCNSVolumeInfo(ctx context.Context, patch map[string]interface{}, volumeID string) error { log := logger.GetLogger(ctx) patchBytes, err := json.Marshal(patch) diff --git a/pkg/csi/service/wcp/controller_vmowned_test.go b/pkg/csi/service/wcp/controller_vmowned_test.go new file mode 100644 index 0000000000..c57e77470b --- /dev/null +++ b/pkg/csi/service/wcp/controller_vmowned_test.go @@ -0,0 +1,83 @@ +/* +Copyright 2026 The Kubernetes Authors. + +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 wcp + +import ( + "context" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "k8s.io/apimachinery/pkg/runtime" + csivolumeinfosvc "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/csivolumeinfo" + csivolumeinfov1alpha1 "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/csivolumeinfo/v1alpha1" +) + +func newCVIScheme() *runtime.Scheme { + s := runtime.NewScheme() + _ = csivolumeinfov1alpha1.AddToScheme(s) + return s +} + +func cviSvcWith(cvis ...*csivolumeinfov1alpha1.CsiVolumeInfo) csivolumeinfosvc.CsiVolumeInfoService { + objs := make([]client.Object, len(cvis)) + for i, c := range cvis { + objs[i] = c + } + c := fake.NewClientBuilder(). + WithScheme(newCVIScheme()). + WithObjects(objs...). + WithStatusSubresource(&csivolumeinfov1alpha1.CsiVolumeInfo{}). + Build() + return csivolumeinfosvc.NewCsiVolumeInfoService(c) +} + +func buildTestCVI(volumeID string, + ownership csivolumeinfov1alpha1.OwnershipState) *csivolumeinfov1alpha1.CsiVolumeInfo { + cvi := &csivolumeinfov1alpha1.CsiVolumeInfo{ + ObjectMeta: metav1.ObjectMeta{ + Name: csivolumeinfov1alpha1.CVINamePrefix + volumeID, + Namespace: csivolumeinfov1alpha1.CVINamespace, + }, + Spec: csivolumeinfov1alpha1.CsiVolumeInfoSpec{VolumeID: volumeID}, + } + cvi.Status.Ownership = ownership + return cvi +} + +func TestRejectSnapshotIfVMManaged_VMManaged_Rejects(t *testing.T) { + svc := cviSvcWith(buildTestCVI("vol-001", csivolumeinfov1alpha1.OwnershipStateVMManaged)) + if err := rejectSnapshotIfVMManaged(context.Background(), svc, "vol-001"); err == nil { + t.Fatal("expected error for VMManaged volume, got nil") + } +} + +func TestRejectSnapshotIfVMManaged_CSIManaged_Allows(t *testing.T) { + svc := cviSvcWith(buildTestCVI("vol-002", csivolumeinfov1alpha1.OwnershipStateCSIManaged)) + if err := rejectSnapshotIfVMManaged(context.Background(), svc, "vol-002"); err != nil { + t.Fatalf("expected nil for CSIManaged volume, got: %v", err) + } +} + +func TestRejectSnapshotIfVMManaged_NoCVI_Allows(t *testing.T) { + svc := cviSvcWith() // empty + if err := rejectSnapshotIfVMManaged(context.Background(), svc, "vol-003"); err != nil { + t.Fatalf("expected nil (fail-open) when no CVI, got: %v", err) + } +} diff --git a/pkg/syncer/admissionhandler/admissionhandler.go b/pkg/syncer/admissionhandler/admissionhandler.go index 4ad92ba99a..52196e1b57 100644 --- a/pkg/syncer/admissionhandler/admissionhandler.go +++ b/pkg/syncer/admissionhandler/admissionhandler.go @@ -63,6 +63,7 @@ var ( featureFileVolumesWithVmServiceEnabled bool featureIsSharedDiskEnabled bool featureIsLinkedCloneSupportEnabled bool + featureGateVMOwnedVolumesEnabled bool ) // watchConfigChange watches on the webhook configuration directory for changes @@ -151,6 +152,7 @@ func StartWebhookServer(ctx context.Context, enableWebhookClientCertVerification featureFileVolumesWithVmServiceEnabled = containerOrchestratorUtility.IsFSSEnabled(ctx, common.FileVolumesWithVmService) featureIsLinkedCloneSupportEnabled = containerOrchestratorUtility.IsFSSEnabled(ctx, common.LinkedCloneSupport) + featureGateVMOwnedVolumesEnabled = containerOrchestratorUtility.IsFSSEnabled(ctx, common.VMOwnedVolumes) if !featureIsLinkedCloneSupportEnabled { go containerOrchestratorUtility.HandleLateEnablementOfCapability(ctx, cnstypes.CnsClusterFlavorWorkload, common.LinkedCloneSupport, "", "") diff --git a/pkg/syncer/admissionhandler/cnscsi_admissionhandler.go b/pkg/syncer/admissionhandler/cnscsi_admissionhandler.go index cdc182017b..c35c83371d 100644 --- a/pkg/syncer/admissionhandler/cnscsi_admissionhandler.go +++ b/pkg/syncer/admissionhandler/cnscsi_admissionhandler.go @@ -4,7 +4,7 @@ import ( "context" "crypto/tls" - _ "crypto/tls/fipsonly" + //_ "crypto/tls/fipsonly" "crypto/x509" "encoding/json" "fmt" @@ -204,6 +204,11 @@ func (h *CSISupervisorWebhook) Handle(ctx context.Context, req admission.Request admissionResp := validatePVC(ctx, &req.AdmissionRequest) resp.AdmissionResponse = *admissionResp.DeepCopy() } + if resp.Allowed { + // Reject PVC deletion when the volume is owned by a VM. + admissionResp := validatePVCDeletionForVMOwnedVolumes(ctx, &req.AdmissionRequest) + resp.AdmissionResponse = *admissionResp.DeepCopy() + } } else if req.Kind.Kind == "CnsFileAccessConfig" { if featureFileVolumesWithVmServiceEnabled { switch req.Operation { @@ -216,6 +221,13 @@ func (h *CSISupervisorWebhook) Handle(ctx context.Context, req admission.Request } } } else if req.Kind.Kind == "VolumeSnapshot" { + if featureGateVMOwnedVolumesEnabled { + admissionResp := validateSnapshotCreateForVMOwnedVolumes(ctx, &req.AdmissionRequest) + resp.AdmissionResponse = *admissionResp.DeepCopy() + if !resp.Allowed { + return + } + } if featureIsLinkedCloneSupportEnabled { admissionResp := validateSnapshotOperationSupervisorRequest(ctx, &req.AdmissionRequest) resp.AdmissionResponse = *admissionResp.DeepCopy() diff --git a/pkg/syncer/admissionhandler/validate_cvi.go b/pkg/syncer/admissionhandler/validate_cvi.go new file mode 100644 index 0000000000..fe0d974f45 --- /dev/null +++ b/pkg/syncer/admissionhandler/validate_cvi.go @@ -0,0 +1,234 @@ +/* +Copyright 2026 The Kubernetes Authors. + +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 admissionhandler + +import ( + "context" + "encoding/json" + "fmt" + + admissionv1 "k8s.io/api/admission/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + snap "github.com/kubernetes-csi/external-snapshotter/client/v8/apis/volumesnapshot/v1" + + csivolumeinfosvc "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/csivolumeinfo" + csivolumeinfov1alpha1 "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/csivolumeinfo/v1alpha1" + "sigs.k8s.io/vsphere-csi-driver/v3/pkg/csi/service/logger" + csitypes "sigs.k8s.io/vsphere-csi-driver/v3/pkg/csi/types" +) + +const ( + // DeleteVMOwnedVolumeErrorMessageFormat is the rejection message returned when + // a PVC delete is attempted while the volume is VM-managed. It takes the PVC + // name as its single argument. + DeleteVMOwnedVolumeErrorMessageFormat = "Cannot delete PVC %s: volume is VM-managed. " + + "Detach the volume from the VM or delete all retaining snapshots first." + + // SnapshotVMOwnedVolumeErrorMessageFormat is the rejection message returned when + // a VolumeSnapshot create is attempted while the volume is VM-managed. It takes + // the PVC name as its single argument. + SnapshotVMOwnedVolumeErrorMessageFormat = "Cannot create snapshot for PVC %s: volume is VM-managed. " + + "Detach the volume from the VM or delete all retaining snapshots first." +) + +// validatePVCDeletionForVMOwnedVolumes checks whether a PVC being deleted has +// a corresponding CsiVolumeInfo in VMManaged state. If so, the deletion is +// rejected to prevent data loss while a VM holds the disk. +// +// This guard is a no-op when the VMOwnedVolumes feature state is disabled, +// when the CsiVolumeInfo cannot be resolved (fail-open to avoid blocking +// unrelated deletes), or when the volume is already CSI-managed. +func validatePVCDeletionForVMOwnedVolumes( + ctx context.Context, req *admissionv1.AdmissionRequest) *admissionv1.AdmissionResponse { + log := logger.GetLogger(ctx) + + if !featureGateVMOwnedVolumesEnabled { + return &admissionv1.AdmissionResponse{Allowed: true} + } + + if req.Operation != admissionv1.Delete { + return &admissionv1.AdmissionResponse{Allowed: true} + } + + // Decode the PVC being deleted. + pvc := &corev1.PersistentVolumeClaim{} + if err := json.Unmarshal(req.OldObject.Raw, pvc); err != nil { + log.Warnf("validatePVCDeletionForVMOwnedVolumes: failed to decode PVC from request: %v; "+ + "allowing deletion (fail-open)", err) + return &admissionv1.AdmissionResponse{Allowed: true} + } + + // Only examine bound PVCs that have a named PV. + if pvc.Status.Phase != corev1.ClaimBound || pvc.Spec.VolumeName == "" { + return &admissionv1.AdmissionResponse{Allowed: true} + } + + k8sclient, err := newK8sClient(ctx) + if err != nil { + log.Warnf("validatePVCDeletionForVMOwnedVolumes: failed to create k8s client: %v; "+ + "allowing deletion (fail-open)", err) + return &admissionv1.AdmissionResponse{Allowed: true} + } + + // Resolve PV → volumeHandle (O(1) Get by name). + pv, err := k8sclient.CoreV1().PersistentVolumes().Get(ctx, pvc.Spec.VolumeName, metav1.GetOptions{}) + if err != nil { + log.Warnf("validatePVCDeletionForVMOwnedVolumes: failed to get PV %q for PVC %s/%s: %v; "+ + "allowing deletion (fail-open)", pvc.Spec.VolumeName, pvc.Namespace, pvc.Name, err) + return &admissionv1.AdmissionResponse{Allowed: true} + } + + if pv.Spec.CSI == nil || pv.Spec.CSI.Driver != csitypes.Name { + return &admissionv1.AdmissionResponse{Allowed: true} + } + + volumeID := pv.Spec.CSI.VolumeHandle + if volumeID == "" { + return &admissionv1.AdmissionResponse{Allowed: true} + } + + // Resolve CsiVolumeInfo by deterministic name (O(1) Get). + cviSvc, err := csivolumeinfosvc.InitCsiVolumeInfoService(ctx) + if err != nil { + log.Warnf("validatePVCDeletionForVMOwnedVolumes: failed to init CVI service for PVC %s/%s: %v; "+ + "allowing deletion (fail-open)", pvc.Namespace, pvc.Name, err) + return &admissionv1.AdmissionResponse{Allowed: true} + } + + cvi, err := cviSvc.GetCsiVolumeInfo(ctx, volumeID) + if err != nil { + log.Warnf("validatePVCDeletionForVMOwnedVolumes: failed to get CsiVolumeInfo for volume %q: %v; "+ + "allowing deletion (fail-open)", volumeID, err) + return &admissionv1.AdmissionResponse{Allowed: true} + } + + if cvi == nil || cvi.Status.Ownership != csivolumeinfov1alpha1.OwnershipStateVMManaged { + return &admissionv1.AdmissionResponse{Allowed: true} + } + + // Volume is VM-managed — reject the deletion. + log.Infof("validatePVCDeletionForVMOwnedVolumes: rejecting delete of PVC %s/%s "+ + "(volumeID=%q, ownership=VMManaged)", pvc.Namespace, pvc.Name, volumeID) + msg := fmt.Sprintf(DeleteVMOwnedVolumeErrorMessageFormat, pvc.Name) + return &admissionv1.AdmissionResponse{ + Allowed: false, + Result: &metav1.Status{ + Message: msg, + Reason: metav1.StatusReasonForbidden, + Code: 403, + }, + } +} + +// validateSnapshotCreateForVMOwnedVolumes rejects VolumeSnapshot CREATE requests +// when the source PVC's volume has ownership=VMManaged. The disk is a plain VMDK +// at that point — no FCD entry exists in CNS — so an FCD snapshot would fail or +// produce an inconsistent result. +// +// Fail-open on resolution errors to avoid blocking unrelated snapshot creates. +func validateSnapshotCreateForVMOwnedVolumes( + ctx context.Context, req *admissionv1.AdmissionRequest) *admissionv1.AdmissionResponse { + log := logger.GetLogger(ctx) + + if !featureGateVMOwnedVolumesEnabled { + return &admissionv1.AdmissionResponse{Allowed: true} + } + + if req.Operation != admissionv1.Create { + return &admissionv1.AdmissionResponse{Allowed: true} + } + + vs := &snap.VolumeSnapshot{} + if err := json.Unmarshal(req.Object.Raw, vs); err != nil { + log.Warnf("validateSnapshotCreateForVMOwnedVolumes: failed to decode VolumeSnapshot: %v; "+ + "allowing (fail-open)", err) + return &admissionv1.AdmissionResponse{Allowed: true} + } + + // Only examine snapshots that name an explicit PVC source. + if vs.Spec.Source.PersistentVolumeClaimName == nil || *vs.Spec.Source.PersistentVolumeClaimName == "" { + return &admissionv1.AdmissionResponse{Allowed: true} + } + pvcName := *vs.Spec.Source.PersistentVolumeClaimName + pvcNamespace := req.Namespace + + k8sclient, err := newK8sClient(ctx) + if err != nil { + log.Warnf("validateSnapshotCreateForVMOwnedVolumes: failed to create k8s client: %v; "+ + "allowing (fail-open)", err) + return &admissionv1.AdmissionResponse{Allowed: true} + } + + pvc, err := k8sclient.CoreV1().PersistentVolumeClaims(pvcNamespace).Get(ctx, pvcName, metav1.GetOptions{}) + if err != nil { + log.Warnf("validateSnapshotCreateForVMOwnedVolumes: failed to get PVC %s/%s: %v; "+ + "allowing (fail-open)", pvcNamespace, pvcName, err) + return &admissionv1.AdmissionResponse{Allowed: true} + } + + if pvc.Status.Phase != corev1.ClaimBound || pvc.Spec.VolumeName == "" { + return &admissionv1.AdmissionResponse{Allowed: true} + } + + pv, err := k8sclient.CoreV1().PersistentVolumes().Get(ctx, pvc.Spec.VolumeName, metav1.GetOptions{}) + if err != nil { + log.Warnf("validateSnapshotCreateForVMOwnedVolumes: failed to get PV %q for PVC %s/%s: %v; "+ + "allowing (fail-open)", pvc.Spec.VolumeName, pvcNamespace, pvcName, err) + return &admissionv1.AdmissionResponse{Allowed: true} + } + + if pv.Spec.CSI == nil || pv.Spec.CSI.Driver != csitypes.Name { + return &admissionv1.AdmissionResponse{Allowed: true} + } + + volumeID := pv.Spec.CSI.VolumeHandle + if volumeID == "" { + return &admissionv1.AdmissionResponse{Allowed: true} + } + + cviSvc, err := csivolumeinfosvc.InitCsiVolumeInfoService(ctx) + if err != nil { + log.Warnf("validateSnapshotCreateForVMOwnedVolumes: failed to init CVI service for PVC %s/%s: %v; "+ + "allowing (fail-open)", pvcNamespace, pvcName, err) + return &admissionv1.AdmissionResponse{Allowed: true} + } + + cvi, err := cviSvc.GetCsiVolumeInfo(ctx, volumeID) + if err != nil { + log.Warnf("validateSnapshotCreateForVMOwnedVolumes: failed to get CsiVolumeInfo for volume %q: %v; "+ + "allowing (fail-open)", volumeID, err) + return &admissionv1.AdmissionResponse{Allowed: true} + } + + if cvi == nil || cvi.Status.Ownership != csivolumeinfov1alpha1.OwnershipStateVMManaged { + return &admissionv1.AdmissionResponse{Allowed: true} + } + + log.Infof("validateSnapshotCreateForVMOwnedVolumes: rejecting VolumeSnapshot %s/%s for PVC %s/%s "+ + "(volumeID=%q, ownership=VMManaged)", req.Namespace, vs.Name, pvcNamespace, pvcName, volumeID) + msg := fmt.Sprintf(SnapshotVMOwnedVolumeErrorMessageFormat, pvcName) + return &admissionv1.AdmissionResponse{ + Allowed: false, + Result: &metav1.Status{ + Message: msg, + Reason: metav1.StatusReasonForbidden, + Code: 403, + }, + } +} diff --git a/pkg/syncer/cnsoperator/controller/add_csivolumeinfo.go b/pkg/syncer/cnsoperator/controller/add_csivolumeinfo.go new file mode 100644 index 0000000000..434d2e3a34 --- /dev/null +++ b/pkg/syncer/cnsoperator/controller/add_csivolumeinfo.go @@ -0,0 +1,25 @@ +/* +Copyright 2026 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controller + +import ( + "sigs.k8s.io/vsphere-csi-driver/v3/pkg/syncer/cnsoperator/controller/csivolumeinfo" +) + +func init() { + AddToManagerFuncs = append(AddToManagerFuncs, csivolumeinfo.Add) +} diff --git a/pkg/syncer/cnsoperator/controller/cnsregistervolume/cnsregistervolume_controller.go b/pkg/syncer/cnsoperator/controller/cnsregistervolume/cnsregistervolume_controller.go index 2f0b0f9795..8ac459e7a4 100644 --- a/pkg/syncer/cnsoperator/controller/cnsregistervolume/cnsregistervolume_controller.go +++ b/pkg/syncer/cnsoperator/controller/cnsregistervolume/cnsregistervolume_controller.go @@ -51,6 +51,8 @@ import ( clientConfig "sigs.k8s.io/controller-runtime/pkg/client/config" apis "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator" cnsregistervolumev1alpha1 "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/cnsregistervolume/v1alpha1" + csivolumeinfosvc "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/csivolumeinfo" + csivolumeinfov1alpha1 "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/csivolumeinfo/v1alpha1" storagepolicyusagev1alpha3 "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/storagepolicy/v1alpha3" volumes "sigs.k8s.io/vsphere-csi-driver/v3/pkg/common/cns-lib/volume" cnsvsphere "sigs.k8s.io/vsphere-csi-driver/v3/pkg/common/cns-lib/vsphere" @@ -152,6 +154,20 @@ func Add(mgr manager.Manager, clusterFlavor cnstypes.CnsClusterFlavor, return err } + // Initialise the CsiVolumeInfo service when the VM-owned volumes feature is + // enabled. The service is needed to create CVIs in VMManaged state during + // deferred-FCD import. The CsiVolumeInfo type is registered on the manager + // scheme so the controller-runtime cache can track those objects. + var csiVolumeInfoService csivolumeinfosvc.CsiVolumeInfoService + if commonco.ContainerOrchestratorUtility.IsFSSEnabled(ctx, common.VMOwnedVolumes) { + if schemeErr := csivolumeinfov1alpha1.AddToScheme(mgr.GetScheme()); schemeErr != nil { + log.Errorf("Failed to add CsiVolumeInfo to scheme. Err: %v", schemeErr) + return schemeErr + } + csiVolumeInfoService = csivolumeinfosvc.NewCsiVolumeInfoService(mgr.GetClient()) + log.Infof("CsiVolumeInfo service initialised for deferred-FCD import support") + } + // eventBroadcaster broadcasts events on cnsregistervolume instances to the // event sink. eventBroadcaster := record.NewBroadcaster() @@ -161,7 +177,7 @@ func Add(mgr manager.Manager, clusterFlavor cnstypes.CnsClusterFlavor, }, ) recorder := eventBroadcaster.NewRecorder(scheme.Scheme, v1.EventSource{Component: apis.GroupName}) - reconciler, err := newReconciler(mgr, configInfo, volumeManager, recorder, volumeInfoService) + reconciler, err := newReconciler(mgr, configInfo, volumeManager, recorder, volumeInfoService, csiVolumeInfoService) if err != nil { log.Errorf("Failed to create reconciler. Err: %v", err) return err @@ -172,7 +188,8 @@ func Add(mgr manager.Manager, clusterFlavor cnstypes.CnsClusterFlavor, // newReconciler returns a new reconcile.Reconciler. func newReconciler(mgr manager.Manager, configInfo *commonconfig.ConfigurationInfo, volumeManager volumes.Manager, recorder record.EventRecorder, - volumeInfoService cnsvolumeinfo.VolumeInfoService) (reconcile.Reconciler, error) { + volumeInfoService cnsvolumeinfo.VolumeInfoService, + csiVolumeInfoService csivolumeinfosvc.CsiVolumeInfoService) (reconcile.Reconciler, error) { ctx, log := logger.GetNewContextWithLogger() k8sclient, err := k8s.NewClient(ctx) if err != nil { @@ -181,7 +198,8 @@ func newReconciler(mgr manager.Manager, configInfo *commonconfig.ConfigurationIn } return &ReconcileCnsRegisterVolume{client: mgr.GetClient(), scheme: mgr.GetScheme(), configInfo: configInfo, volumeManager: volumeManager, recorder: recorder, - volumeInfoService: volumeInfoService, k8sclient: k8sclient}, nil + volumeInfoService: volumeInfoService, k8sclient: k8sclient, + csiVolumeInfoService: csiVolumeInfoService}, nil } // add adds a new Controller to mgr with r as the reconcile.Reconciler. @@ -213,13 +231,14 @@ var _ reconcile.Reconciler = &ReconcileCnsRegisterVolume{} type ReconcileCnsRegisterVolume struct { // This client, initialized using mgr.Client() above, is a split client // that reads objects from the cache and writes to the apiserver. - client client.Client - scheme *runtime.Scheme - configInfo *commonconfig.ConfigurationInfo - volumeManager volumes.Manager - recorder record.EventRecorder - volumeInfoService cnsvolumeinfo.VolumeInfoService - k8sclient clientset.Interface + client client.Client + scheme *runtime.Scheme + configInfo *commonconfig.ConfigurationInfo + volumeManager volumes.Manager + recorder record.EventRecorder + volumeInfoService cnsvolumeinfo.VolumeInfoService + k8sclient clientset.Interface + csiVolumeInfoService csivolumeinfosvc.CsiVolumeInfoService } // Reconcile reads that state of the cluster for a CnsRegisterVolume object @@ -304,6 +323,12 @@ func (r *ReconcileCnsRegisterVolume) Reconcile(ctx context.Context, return reconcile.Result{RequeueAfter: timeout}, nil } + // Deferred-FCD import: create PV and CsiVolumeInfo keyed on the PVC UID + // without registering an FCD. This bypasses all FCD and CNS DB operations. + if instance.Spec.DeferFcdRegistration { + return r.reconcileDeferFcdRegistration(ctx, instance, request, timeout) + } + vc, err := cnsvsphere.GetVirtualCenterInstance(ctx, r.configInfo, false) if err != nil { msg := fmt.Sprintf("Failed to get virtual center instance with error: %+v", err) @@ -1399,6 +1424,13 @@ func patchCnsRegisterVolumeStatus(ctx context.Context, cnsOperatorClient client. statusMap["error"] = newObj.Status.Error } + if newObj.Status.VolumeID != "" { + statusMap["volumeID"] = newObj.Status.VolumeID + } + if newObj.Status.PvName != "" { + statusMap["pvName"] = newObj.Status.PvName + } + statusPatch := map[string]interface{}{ "status": statusMap, } diff --git a/pkg/syncer/cnsoperator/controller/cnsregistervolume/cnsregistervolume_controller_test.go b/pkg/syncer/cnsoperator/controller/cnsregistervolume/cnsregistervolume_controller_test.go index 8abde4bcfc..8daa8436e9 100644 --- a/pkg/syncer/cnsoperator/controller/cnsregistervolume/cnsregistervolume_controller_test.go +++ b/pkg/syncer/cnsoperator/controller/cnsregistervolume/cnsregistervolume_controller_test.go @@ -268,6 +268,23 @@ func (m *mockVolumeManager) ReRegisterVolume(ctx context.Context, volumeID strin return nil } +func (m *mockVolumeManager) UnregisterVolumeEx(ctx context.Context, volumeID string) (string, string, error) { + return "", "", nil +} + +func (m *mockVolumeManager) QueryPendingUnregisters(ctx context.Context) ( + []cnsvolume.PendingUnregisterRecord, error) { + return nil, nil +} + +func (m *mockVolumeManager) AckUnregister(ctx context.Context, volumeID string) error { + return nil +} + +func (m *mockVolumeManager) GetDiskFolderURL(ctx context.Context, datastorePath string) (string, error) { + return "", nil +} + type mockCOCommon struct{} func (m *mockCOCommon) ListPVCs(ctx context.Context, namespace string) []*corev1.PersistentVolumeClaim { diff --git a/pkg/syncer/cnsoperator/controller/cnsregistervolume/cnsregistervolume_import.go b/pkg/syncer/cnsoperator/controller/cnsregistervolume/cnsregistervolume_import.go new file mode 100644 index 0000000000..32c04652b4 --- /dev/null +++ b/pkg/syncer/cnsoperator/controller/cnsregistervolume/cnsregistervolume_import.go @@ -0,0 +1,311 @@ +/* +Copyright 2026 The Kubernetes Authors. + +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 cnsregistervolume + +import ( + "context" + "encoding/json" + "fmt" + "time" + + v1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + apitypes "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + cnsregistervolumev1alpha1 "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/cnsregistervolume/v1alpha1" + csivolumeinfosvc "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/csivolumeinfo" + csivolumeinfov1alpha1 "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/csivolumeinfo/v1alpha1" + "sigs.k8s.io/vsphere-csi-driver/v3/pkg/csi/service/common" + "sigs.k8s.io/vsphere-csi-driver/v3/pkg/csi/service/common/commonco" + "sigs.k8s.io/vsphere-csi-driver/v3/pkg/csi/service/logger" +) + +const ( + // reasonImportSucceeded is the condition reason set on a CsiVolumeInfo created + // for an imported VM disk (deferFcdRegistration=true path). The disk starts in + // VMManaged state with no FCD; the FCD is created lazily at detach time. + reasonImportSucceeded = "ImportSucceeded" +) + +// isPVCBoundFn is a package-level variable so tests can substitute a stub +// without gomonkey. Production code uses the real isPVCBound from util.go. +var isPVCBoundFn = isPVCBound + +// reconcileDeferFcdRegistration handles CnsRegisterVolume CRs that carry +// deferFcdRegistration=true. Instead of registering an FCD, it: +// 1. Reads the pre-created PVC (created by vm-operator). +// 2. Derives volumeID = string(pvc.UID) — the synthetic identity used until detach. +// 3. Creates a PV pre-bound to the PVC with volumeHandle = volumeID. +// 4. Waits for PV↔PVC binding. +// 5. Creates a CsiVolumeInfo in VMManaged state with the VM entry and disk metadata. +// 6. Marks the CnsRegisterVolume as registered, recording volumeID and pvName. +// +// No FCD, CNS DB entry, or vCenter call is made. The FCD is created lazily when +// the disk is eventually detached via the CsiVolumeInfo register path. +func (r *ReconcileCnsRegisterVolume) reconcileDeferFcdRegistration(ctx context.Context, + instance *cnsregistervolumev1alpha1.CnsRegisterVolume, + request reconcile.Request, timeout time.Duration) (reconcile.Result, error) { + + log := logger.GetLogger(ctx) + log.Infof("reconcileDeferFcdRegistration: starting for CnsRegisterVolume %q in namespace %q", + instance.Name, instance.Namespace) + + if !commonco.ContainerOrchestratorUtility.IsFSSEnabled(ctx, common.VMOwnedVolumes) { + msg := "deferFcdRegistration requires the VMOwnedVolumes feature to be enabled" + log.Errorf("reconcileDeferFcdRegistration: %s", msg) + setInstanceError(ctx, r, instance, msg) + return reconcile.Result{RequeueAfter: timeout}, nil + } + + if r.csiVolumeInfoService == nil { + msg := "CsiVolumeInfo service is not initialised; cannot process deferred-FCD import" + log.Errorf("reconcileDeferFcdRegistration: %s", msg) + setInstanceError(ctx, r, instance, msg) + return reconcile.Result{RequeueAfter: timeout}, nil + } + + // Step 1: read the PVC created by vm-operator. + pvc, err := r.k8sclient.CoreV1().PersistentVolumeClaims(instance.Namespace). + Get(ctx, instance.Spec.PvcName, metav1.GetOptions{}) + if err != nil { + if apierrors.IsNotFound(err) { + msg := fmt.Sprintf("PVC %q not found in namespace %q; retrying", + instance.Spec.PvcName, instance.Namespace) + log.Warnf("reconcileDeferFcdRegistration: %s", msg) + setInstanceError(ctx, r, instance, msg) + return reconcile.Result{RequeueAfter: timeout}, nil + } + msg := fmt.Sprintf("failed to get PVC %q: %v", instance.Spec.PvcName, err) + log.Errorf("reconcileDeferFcdRegistration: %s", msg) + setInstanceError(ctx, r, instance, msg) + return reconcile.Result{RequeueAfter: timeout}, nil + } + log.Infof("reconcileDeferFcdRegistration: read PVC %q (uid=%q) in namespace %q", + pvc.Name, pvc.UID, pvc.Namespace) + + // Step 2: derive volumeID and pvName from PVC UID. + volumeID := string(pvc.UID) + pvName := staticPvNamePrefix + volumeID + + // Step 3: resolve PV parameters from the PVC. + if pvc.Spec.StorageClassName == nil || *pvc.Spec.StorageClassName == "" { + msg := fmt.Sprintf("PVC %q has no storageClassName; cannot create PV", pvc.Name) + log.Errorf("reconcileDeferFcdRegistration: %s", msg) + setInstanceError(ctx, r, instance, msg) + return reconcile.Result{RequeueAfter: timeout}, nil + } + storageClassName := *pvc.Spec.StorageClassName + + requestStorage, ok := pvc.Spec.Resources.Requests[v1.ResourceStorage] + if !ok { + msg := fmt.Sprintf("PVC %q has no storage request; cannot size PV", pvc.Name) + log.Errorf("reconcileDeferFcdRegistration: %s", msg) + setInstanceError(ctx, r, instance, msg) + return reconcile.Result{RequeueAfter: timeout}, nil + } + capacity := requestStorage.DeepCopy() + + accessMode := instance.Spec.AccessMode + if accessMode == "" { + accessMode = v1.ReadWriteOnce + } + + volumeMode := instance.Spec.VolumeMode + if volumeMode == "" && pvc.Spec.VolumeMode != nil { + volumeMode = *pvc.Spec.VolumeMode + } + + // Step 4: create the PV if it does not already exist. + existingPV, err := r.k8sclient.CoreV1().PersistentVolumes().Get(ctx, pvName, metav1.GetOptions{}) + if err != nil && !apierrors.IsNotFound(err) { + msg := fmt.Sprintf("failed to check for existing PV %q: %v", pvName, err) + log.Errorf("reconcileDeferFcdRegistration: %s", msg) + setInstanceError(ctx, r, instance, msg) + return reconcile.Result{RequeueAfter: timeout}, nil + } + + if apierrors.IsNotFound(err) { + claimRef := &v1.ObjectReference{ + Kind: "PersistentVolumeClaim", + APIVersion: "v1", + Namespace: instance.Namespace, + Name: instance.Spec.PvcName, + UID: pvc.UID, + } + pvSpec := getPersistentVolumeSpec(pvName, volumeID, capacity, + accessMode, volumeMode, storageClassName, claimRef, + instance.Namespace, instance.Name) + createdPV, createErr := r.k8sclient.CoreV1().PersistentVolumes().Create(ctx, pvSpec, metav1.CreateOptions{}) + if createErr != nil { + msg := fmt.Sprintf("failed to create PV %q: %v", pvName, createErr) + log.Errorf("reconcileDeferFcdRegistration: %s", msg) + setInstanceError(ctx, r, instance, msg) + return reconcile.Result{RequeueAfter: timeout}, nil + } + existingPV = createdPV + log.Infof("reconcileDeferFcdRegistration: created PV %q (volumeHandle=%q)", pvName, volumeID) + } else { + log.Infof("reconcileDeferFcdRegistration: PV %q already exists, reusing", pvName) + } + + // Step 5: wait for PV↔PVC binding. + bound, bindErr := isPVCBoundFn(ctx, r.k8sclient, pvc, time.Minute) + if !bound { + msg := fmt.Sprintf("PVC %q did not bind within timeout: %v", pvc.Name, bindErr) + log.Errorf("reconcileDeferFcdRegistration: %s", msg) + setInstanceError(ctx, r, instance, msg) + return reconcile.Result{RequeueAfter: timeout}, nil + } + log.Infof("reconcileDeferFcdRegistration: PVC %q is bound to PV %q", pvc.Name, pvName) + + // Step 6: create CsiVolumeInfo in VMManaged state. + if err := r.ensureCsiVolumeInfoForImport(ctx, instance, volumeID, pvName, existingPV); err != nil { + msg := fmt.Sprintf("failed to ensure CsiVolumeInfo for volumeID %q: %v", volumeID, err) + log.Errorf("reconcileDeferFcdRegistration: %s", msg) + setInstanceError(ctx, r, instance, msg) + return reconcile.Result{RequeueAfter: timeout}, nil + } + log.Infof("reconcileDeferFcdRegistration: CsiVolumeInfo ready for volumeID %q (VMManaged)", volumeID) + + // Step 7: mark the CnsRegisterVolume as successfully registered. + setInstanceOwnerRef(instance, instance.Spec.PvcName, pvc.UID) + if err := updateCnsRegisterVolume(ctx, r.client, instance); err != nil { + msg := fmt.Sprintf("failed to set ownerRef on CnsRegisterVolume: %v", err) + log.Errorf("reconcileDeferFcdRegistration: %s", msg) + setInstanceError(ctx, r, instance, msg) + return reconcile.Result{RequeueAfter: timeout}, nil + } + + // Re-fetch after metadata update to avoid stale resourceVersion on status patch. + if err := r.client.Get(ctx, apitypes.NamespacedName{ + Name: instance.Name, + Namespace: instance.Namespace, + }, instance); err != nil { + msg := fmt.Sprintf("failed to re-fetch CnsRegisterVolume after ownerRef update: %v", err) + log.Errorf("reconcileDeferFcdRegistration: %s", msg) + setInstanceError(ctx, r, instance, msg) + return reconcile.Result{RequeueAfter: timeout}, nil + } + + origInstance := instance.DeepCopy() + instance.Status.Registered = true + instance.Status.Error = "" + instance.Status.VolumeID = volumeID + instance.Status.PvName = pvName + if err := patchCnsRegisterVolumeStatus(ctx, r.client, origInstance, instance); err != nil { + msg := fmt.Sprintf("failed to patch CnsRegisterVolume status: %v", err) + log.Errorf("reconcileDeferFcdRegistration: %s", msg) + return reconcile.Result{RequeueAfter: timeout}, nil + } + + backOffDurationMapMutex.Lock() + delete(backOffDuration, request.NamespacedName) + backOffDurationMapMutex.Unlock() + + successMsg := fmt.Sprintf("Successfully imported disk as VM-managed volume %q in namespace %q", + volumeID, instance.Namespace) + recordEvent(ctx, r, instance, v1.EventTypeNormal, successMsg) + log.Infof("reconcileDeferFcdRegistration: %s", successMsg) + return reconcile.Result{}, nil +} + +// ensureCsiVolumeInfoForImport creates (or verifies) the CsiVolumeInfo CR for an +// imported disk. The CVI is created directly in VMManaged state — there is no prior +// CSIManaged state for imported disks. If the CVI already exists (idempotent retry), +// its status is patched to VMManaged/Succeeded regardless. +func (r *ReconcileCnsRegisterVolume) ensureCsiVolumeInfoForImport(ctx context.Context, + instance *cnsregistervolumev1alpha1.CnsRegisterVolume, + volumeID, pvName string, pv *v1.PersistentVolume) error { + + log := logger.GetLogger(ctx) + + blockOwnerDeletion := true + cvi := &csivolumeinfov1alpha1.CsiVolumeInfo{ + ObjectMeta: metav1.ObjectMeta{ + Name: csivolumeinfosvc.GetCsiVolumeInfoCRName(volumeID), + Namespace: csivolumeinfov1alpha1.CVINamespace, + Finalizers: []string{ + csivolumeinfov1alpha1.VolumeProtectionFinalizer, + }, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: "v1", + Kind: "PersistentVolume", + Name: pv.Name, + UID: pv.UID, + BlockOwnerDeletion: &blockOwnerDeletion, + }, + }, + }, + Spec: csivolumeinfov1alpha1.CsiVolumeInfoSpec{ + VolumeID: volumeID, + PVCName: instance.Spec.PvcName, + PVCNamespace: instance.Namespace, + PVName: pvName, + DiskUUID: instance.Spec.DiskUUID, + DiskPath: instance.Spec.DiskURLPath, + VMs: []csivolumeinfov1alpha1.VirtualMachineRef{ + { + VMName: instance.Spec.VMName, + VMInstanceUUID: instance.Spec.VMInstanceUUID, + }, + }, + }, + } + + if err := r.csiVolumeInfoService.CreateCsiVolumeInfo(ctx, cvi); err != nil { + return fmt.Errorf("CreateCsiVolumeInfo: %w", err) + } + log.Infof("ensureCsiVolumeInfoForImport: CsiVolumeInfo %q created or already exists for volumeID %q", + cvi.Name, volumeID) + + // Patch status to VMManaged/Succeeded. This is idempotent — patching again on + // a retry is safe because the status subresource is always overwritten. + statusPatch := buildImportStatusPatch(cvi.Generation) + if err := r.csiVolumeInfoService.PatchCsiVolumeInfoStatus(ctx, volumeID, statusPatch); err != nil { + return fmt.Errorf("PatchCsiVolumeInfoStatus: %w", err) + } + log.Infof("ensureCsiVolumeInfoForImport: status patched to VMManaged/Succeeded for volumeID %q", volumeID) + return nil +} + +// buildImportStatusPatch returns the JSON merge-patch bytes for the CsiVolumeInfo +// status subresource, setting ownership=VMManaged, phase=Succeeded, and the +// ImportSucceeded condition. generation is the current spec generation of the CVI; +// after creation it is typically 1. +func buildImportStatusPatch(generation int64) []byte { + cond := map[string]interface{}{ + "type": "Ready", + "status": string(metav1.ConditionTrue), + "reason": reasonImportSucceeded, + "message": "", + "lastTransitionTime": metav1.Now().UTC().Format(time.RFC3339), + } + statusMap := map[string]interface{}{ + "status": map[string]interface{}{ + "ownership": string(csivolumeinfov1alpha1.OwnershipStateVMManaged), + "phase": string(csivolumeinfov1alpha1.PhaseSucceeded), + "observedGeneration": generation, + "error": "", + "conditions": []interface{}{cond}, + }, + } + b, _ := json.Marshal(statusMap) + return b +} diff --git a/pkg/syncer/cnsoperator/controller/cnsregistervolume/cnsregistervolume_import_test.go b/pkg/syncer/cnsoperator/controller/cnsregistervolume/cnsregistervolume_import_test.go new file mode 100644 index 0000000000..bd678b6f27 --- /dev/null +++ b/pkg/syncer/cnsoperator/controller/cnsregistervolume/cnsregistervolume_import_test.go @@ -0,0 +1,424 @@ +/* +Copyright 2026 The Kubernetes Authors. + +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 cnsregistervolume + +import ( + "context" + "testing" + "time" + + cnstypes "github.com/vmware/govmomi/cns/types" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + clientset "k8s.io/client-go/kubernetes" + k8sfake "k8s.io/client-go/kubernetes/fake" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + cnsvolume "sigs.k8s.io/vsphere-csi-driver/v3/pkg/common/cns-lib/volume" + + apis "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator" + cnsregistervolumev1alpha1 "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/cnsregistervolume/v1alpha1" + csivolumeinfosvc "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/csivolumeinfo" + csivolumeinfov1alpha1 "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/csivolumeinfo/v1alpha1" + "sigs.k8s.io/vsphere-csi-driver/v3/pkg/csi/service/common" + "sigs.k8s.io/vsphere-csi-driver/v3/pkg/csi/service/common/commonco" +) + +// importTestCO is a minimal CO interface for import tests that controls only the +// VMOwnedVolumes FSS. Unneeded methods are delegated to the shared mockCOCommon. +type importTestCO struct { + mockCOCommon + vmOwnedVolumesEnabled bool +} + +func (c *importTestCO) IsFSSEnabled(_ context.Context, featureName string) bool { + if featureName == common.VMOwnedVolumes { + return c.vmOwnedVolumesEnabled + } + return false +} + +// newImportScheme creates a runtime.Scheme with all cns operator types plus +// CsiVolumeInfo and core types registered. +func newImportScheme(t *testing.T) *runtime.Scheme { + t.Helper() + s := runtime.NewScheme() + if err := apis.AddToScheme(s); err != nil { + t.Fatalf("AddToScheme cns operator: %v", err) + } + if err := csivolumeinfov1alpha1.AddToScheme(s); err != nil { + t.Fatalf("AddToScheme CsiVolumeInfo: %v", err) + } + if err := corev1.AddToScheme(s); err != nil { + t.Fatalf("AddToScheme core: %v", err) + } + return s +} + +// importTestPVC returns a PVC resembling one created by vm-operator before the CRV. +func importTestPVC(name, namespace string) *corev1.PersistentVolumeClaim { + sc := "vsan-default-storage-policy" + fs := corev1.PersistentVolumeFilesystem + return &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + UID: types.UID("f47ac10b-58cc-4372-a567-0e02b2c3d479"), + }, + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + StorageClassName: &sc, + VolumeMode: &fs, + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("100Gi"), + }, + }, + }, + } +} + +// importTestCRV returns a CnsRegisterVolume with deferFcdRegistration=true. +func importTestCRV(name, namespace, pvcName string) *cnsregistervolumev1alpha1.CnsRegisterVolume { + return &cnsregistervolumev1alpha1.CnsRegisterVolume{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Finalizers: []string{ + "cns.vmware.com/register-volume", + }, + }, + Spec: cnsregistervolumev1alpha1.CnsRegisterVolumeSpec{ + PvcName: pvcName, + DeferFcdRegistration: true, + DiskURLPath: "[vsanDatastore] testns/imported-vm/disk-0.vmdk", + AccessMode: corev1.ReadWriteOnce, + VMName: "imported-vm", + VMInstanceUUID: "502e71fa-1234-5678-9abc-def012345678", + DiskUUID: "6000C29a-bcde-1234-5678-9abcdef01234", + }, + } +} + +// newImportReconciler builds a ReconcileCnsRegisterVolume for deferred-FCD tests. +// The k8s fake client is seeded with pvc (nil = absent); the controller-runtime +// client holds the CRV and serves as the CVI backend. +func newImportReconciler(t *testing.T, + pvc *corev1.PersistentVolumeClaim, + crv *cnsregistervolumev1alpha1.CnsRegisterVolume, + fssEnabled bool, +) *ReconcileCnsRegisterVolume { + t.Helper() + + s := newImportScheme(t) + + crCtrl := fake.NewClientBuilder(). + WithScheme(s). + WithObjects(crv). + WithStatusSubresource( + crv, + &csivolumeinfov1alpha1.CsiVolumeInfo{}, + ). + Build() + + var k8sObjs []runtime.Object + if pvc != nil { + k8sObjs = append(k8sObjs, pvc) + } + k8sClient := k8sfake.NewSimpleClientset(k8sObjs...) + + broadcaster := record.NewBroadcaster() + recorder := broadcaster.NewRecorder(runtime.NewScheme(), corev1.EventSource{Component: "test"}) + + commonco.ContainerOrchestratorUtility = &importTestCO{vmOwnedVolumesEnabled: fssEnabled} + backOffDuration = make(map[types.NamespacedName]time.Duration) + + return &ReconcileCnsRegisterVolume{ + client: crCtrl, + scheme: s, + volumeManager: &mockVolumeManager{}, + recorder: recorder, + k8sclient: k8sClient, + csiVolumeInfoService: csivolumeinfosvc.NewCsiVolumeInfoService(crCtrl), + } +} + +// stubIsPVCBound sets isPVCBoundFn to return (true, nil) and restores the +// original on test cleanup. +func stubIsPVCBound(t *testing.T) { + t.Helper() + origFn := isPVCBoundFn + isPVCBoundFn = func(_ context.Context, _ clientset.Interface, + _ *corev1.PersistentVolumeClaim, _ time.Duration) (bool, error) { + return true, nil + } + t.Cleanup(func() { isPVCBoundFn = origFn }) +} + +// TestReconcileDeferFcd_HappyPath verifies that a successful import creates the +// PV with the correct volumeHandle, the CVI in VMManaged state, and marks the +// CRV as registered with volumeID and pvName set. +func TestReconcileDeferFcd_HappyPath(t *testing.T) { + ctx := context.Background() + pvc := importTestPVC("imported-vm-1a2b", "testns") + crv := importTestCRV("imported-vm-1a2b", "testns", "imported-vm-1a2b") + stubIsPVCBound(t) + r := newImportReconciler(t, pvc, crv, true) + + res, err := r.reconcileDeferFcdRegistration(ctx, crv, + reconcile.Request{NamespacedName: types.NamespacedName{Name: crv.Name, Namespace: crv.Namespace}}, + time.Second) + + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if res.RequeueAfter != 0 { + t.Errorf("expected zero result, got: %+v", res) + } + + expectedVolumeID := string(pvc.UID) + expectedPVName := staticPvNamePrefix + expectedVolumeID + + // PV must exist with volumeHandle == PVC UID and reclaim policy Delete. + pv, pvErr := r.k8sclient.CoreV1().PersistentVolumes().Get(ctx, expectedPVName, metav1.GetOptions{}) + if pvErr != nil { + t.Fatalf("PV not found: %v", pvErr) + } + if pv.Spec.CSI == nil || pv.Spec.CSI.VolumeHandle != expectedVolumeID { + t.Errorf("PV volumeHandle = %q; want %q", pv.Spec.CSI.VolumeHandle, expectedVolumeID) + } + if pv.Spec.PersistentVolumeReclaimPolicy != corev1.PersistentVolumeReclaimDelete { + t.Errorf("PV reclaimPolicy = %q; want Delete", pv.Spec.PersistentVolumeReclaimPolicy) + } + if pv.Spec.ClaimRef == nil || pv.Spec.ClaimRef.Name != pvc.Name { + t.Errorf("PV claimRef.Name = %q; want %q", pv.Spec.ClaimRef.Name, pvc.Name) + } + + // CVI must exist in VMManaged/Succeeded state with correct metadata. + cviSvc := csivolumeinfosvc.NewCsiVolumeInfoService(r.client) + cvi, cviErr := cviSvc.GetCsiVolumeInfo(ctx, expectedVolumeID) + if cviErr != nil { + t.Fatalf("CsiVolumeInfo not found: %v", cviErr) + } + if cvi.Status.Ownership != csivolumeinfov1alpha1.OwnershipStateVMManaged { + t.Errorf("CVI ownership = %q; want VMManaged", cvi.Status.Ownership) + } + if cvi.Status.Phase != csivolumeinfov1alpha1.PhaseSucceeded { + t.Errorf("CVI phase = %q; want Succeeded", cvi.Status.Phase) + } + if len(cvi.Spec.VMs) != 1 || cvi.Spec.VMs[0].VMName != crv.Spec.VMName { + t.Errorf("CVI VMs = %+v; want one entry with vmName=%q", cvi.Spec.VMs, crv.Spec.VMName) + } + hasFinalizer := false + for _, f := range cvi.Finalizers { + if f == csivolumeinfov1alpha1.VolumeProtectionFinalizer { + hasFinalizer = true + } + } + if !hasFinalizer { + t.Errorf("CVI missing finalizer %q", csivolumeinfov1alpha1.VolumeProtectionFinalizer) + } + + // CRV status: registered=true, volumeID and pvName set. + updated := &cnsregistervolumev1alpha1.CnsRegisterVolume{} + if getErr := r.client.Get(ctx, + types.NamespacedName{Name: crv.Name, Namespace: crv.Namespace}, updated); getErr != nil { + t.Fatalf("failed to get CRV: %v", getErr) + } + if !updated.Status.Registered { + t.Errorf("CRV registered = false; want true") + } + if updated.Status.VolumeID != expectedVolumeID { + t.Errorf("CRV volumeID = %q; want %q", updated.Status.VolumeID, expectedVolumeID) + } + if updated.Status.PvName != expectedPVName { + t.Errorf("CRV pvName = %q; want %q", updated.Status.PvName, expectedPVName) + } +} + +// TestReconcileDeferFcd_Idempotent verifies that reconcileDeferFcdRegistration +// is safe to call multiple times: PV and CVI already exist on the second run. +func TestReconcileDeferFcd_Idempotent(t *testing.T) { + ctx := context.Background() + pvc := importTestPVC("imported-vm-idem", "testns") + crv := importTestCRV("imported-vm-idem", "testns", "imported-vm-idem") + stubIsPVCBound(t) + r := newImportReconciler(t, pvc, crv, true) + + req := reconcile.Request{NamespacedName: types.NamespacedName{Name: crv.Name, Namespace: crv.Namespace}} + + // First run. + if _, err := r.reconcileDeferFcdRegistration(ctx, crv, req, time.Second); err != nil { + t.Fatalf("first run: %v", err) + } + + // Re-fetch CRV to get updated resourceVersion. + if err := r.client.Get(ctx, req.NamespacedName, crv); err != nil { + t.Fatalf("re-fetch crv: %v", err) + } + + // Second run must succeed without errors. + res, err := r.reconcileDeferFcdRegistration(ctx, crv, req, time.Second) + if err != nil { + t.Fatalf("second run: %v", err) + } + if res.RequeueAfter != 0 { + t.Errorf("expected zero result on second run, got: %+v", res) + } +} + +// TestReconcileDeferFcd_PVCNotFound verifies that a missing PVC causes a requeue +// with an error recorded on the CRV. +func TestReconcileDeferFcd_PVCNotFound(t *testing.T) { + ctx := context.Background() + crv := importTestCRV("imported-vm-nopvc", "testns", "imported-vm-nopvc") + r := newImportReconciler(t, nil /* no PVC */, crv, true) + + res, err := r.reconcileDeferFcdRegistration(ctx, crv, + reconcile.Request{NamespacedName: types.NamespacedName{Name: crv.Name, Namespace: crv.Namespace}}, + time.Second) + + if err != nil { + t.Fatalf("unexpected returned error (should be set on CRV status only): %v", err) + } + if res.RequeueAfter == 0 { + t.Errorf("expected requeue when PVC missing, got zero result") + } + + updated := &cnsregistervolumev1alpha1.CnsRegisterVolume{} + if getErr := r.client.Get(ctx, + types.NamespacedName{Name: crv.Name, Namespace: crv.Namespace}, updated); getErr != nil { + t.Fatalf("failed to get CRV: %v", getErr) + } + if updated.Status.Registered { + t.Errorf("CRV registered = true; want false when PVC missing") + } + if updated.Status.Error == "" { + t.Errorf("CRV error empty; want a descriptive error when PVC missing") + } +} + +// TestReconcileDeferFcd_FSSDisabled verifies that when VMOwnedVolumes is +// disabled, the handler requeues without creating any CVI or PV. +func TestReconcileDeferFcd_FSSDisabled(t *testing.T) { + ctx := context.Background() + pvc := importTestPVC("imported-vm-nofss", "testns") + crv := importTestCRV("imported-vm-nofss", "testns", "imported-vm-nofss") + r := newImportReconciler(t, pvc, crv, false /* FSS off */) + + res, err := r.reconcileDeferFcdRegistration(ctx, crv, + reconcile.Request{NamespacedName: types.NamespacedName{Name: crv.Name, Namespace: crv.Namespace}}, + time.Second) + + if err != nil { + t.Fatalf("unexpected returned error: %v", err) + } + if res.RequeueAfter == 0 { + t.Errorf("expected requeue when FSS disabled, got zero result") + } + + updated := &cnsregistervolumev1alpha1.CnsRegisterVolume{} + if getErr := r.client.Get(ctx, + types.NamespacedName{Name: crv.Name, Namespace: crv.Namespace}, updated); getErr != nil { + t.Fatalf("get CRV: %v", getErr) + } + if updated.Status.Registered { + t.Errorf("CRV registered = true; want false when FSS disabled") + } + + // No CVI should have been created. + cvi := &csivolumeinfov1alpha1.CsiVolumeInfo{} + getErr := r.client.Get(ctx, types.NamespacedName{ + Name: csivolumeinfosvc.GetCsiVolumeInfoCRName(string(pvc.UID)), + Namespace: csivolumeinfov1alpha1.CVINamespace, + }, cvi) + if getErr == nil { + t.Errorf("CsiVolumeInfo should not exist when FSS disabled, but was found") + } +} + +// TestReconcileDeferFcd_DiskMetadataPropagated verifies that diskUUID, +// diskPath, vmName, and vmInstanceUUID are correctly propagated from the +// CRV spec to the CsiVolumeInfo spec. +func TestReconcileDeferFcd_DiskMetadataPropagated(t *testing.T) { + ctx := context.Background() + pvc := importTestPVC("imported-vm-diskmd", "testns") + crv := importTestCRV("imported-vm-diskmd", "testns", "imported-vm-diskmd") + stubIsPVCBound(t) + r := newImportReconciler(t, pvc, crv, true) + + if _, err := r.reconcileDeferFcdRegistration(ctx, crv, + reconcile.Request{NamespacedName: types.NamespacedName{Name: crv.Name, Namespace: crv.Namespace}}, + time.Second); err != nil { + t.Fatalf("reconcile: %v", err) + } + + cviSvc := csivolumeinfosvc.NewCsiVolumeInfoService(r.client) + cvi, err := cviSvc.GetCsiVolumeInfo(ctx, string(pvc.UID)) + if err != nil { + t.Fatalf("CVI not found: %v", err) + } + if cvi.Spec.DiskUUID != crv.Spec.DiskUUID { + t.Errorf("CVI.spec.diskUUID = %q; want %q", cvi.Spec.DiskUUID, crv.Spec.DiskUUID) + } + if cvi.Spec.DiskPath != crv.Spec.DiskURLPath { + t.Errorf("CVI.spec.diskPath = %q; want %q", cvi.Spec.DiskPath, crv.Spec.DiskURLPath) + } + if len(cvi.Spec.VMs) != 1 { + t.Fatalf("CVI.spec.vms len = %d; want 1", len(cvi.Spec.VMs)) + } + if cvi.Spec.VMs[0].VMName != crv.Spec.VMName { + t.Errorf("CVI.spec.vms[0].vmName = %q; want %q", cvi.Spec.VMs[0].VMName, crv.Spec.VMName) + } + if cvi.Spec.VMs[0].VMInstanceUUID != crv.Spec.VMInstanceUUID { + t.Errorf("CVI.spec.vms[0].vmInstanceUUID = %q; want %q", + cvi.Spec.VMs[0].VMInstanceUUID, crv.Spec.VMInstanceUUID) + } +} + +// TestReconcileDeferFcd_NoFCDCall verifies that no CreateVolume call is issued +// during a successful import (no FCD registration). +func TestReconcileDeferFcd_NoFCDCall(t *testing.T) { + ctx := context.Background() + pvc := importTestPVC("imported-vm-nofcd", "testns") + crv := importTestCRV("imported-vm-nofcd", "testns", "imported-vm-nofcd") + stubIsPVCBound(t) + + createVolumeCalled := false + r := newImportReconciler(t, pvc, crv, true) + r.volumeManager = &mockVolumeManager{ + createVolumeFunc: func(_ context.Context, _ *cnstypes.CnsVolumeCreateSpec, + _ interface{}) (*cnsvolume.CnsVolumeInfo, string, error) { + createVolumeCalled = true + return nil, "", nil + }, + } + + if _, err := r.reconcileDeferFcdRegistration(ctx, crv, + reconcile.Request{NamespacedName: types.NamespacedName{Name: crv.Name, Namespace: crv.Namespace}}, + time.Second); err != nil { + t.Fatalf("reconcile: %v", err) + } + + if createVolumeCalled { + t.Errorf("CreateVolume was called; deferred-FCD import must not touch the FCD API") + } +} diff --git a/pkg/syncer/cnsoperator/controller/csivolumeinfo/add_csivolumeinfo.go b/pkg/syncer/cnsoperator/controller/csivolumeinfo/add_csivolumeinfo.go new file mode 100644 index 0000000000..1ab82548f4 --- /dev/null +++ b/pkg/syncer/cnsoperator/controller/csivolumeinfo/add_csivolumeinfo.go @@ -0,0 +1,79 @@ +/* +Copyright 2026 The Kubernetes Authors. + +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 csivolumeinfo implements the CsiVolumeInfo controller that watches +// CsiVolumeInfo CRs and declaratively reconciles volume ownership transitions +// between CSI-managed (FCD) and VM-managed (VMDK) states. +package csivolumeinfo + +import ( + cnstypes "github.com/vmware/govmomi/cns/types" + "sigs.k8s.io/controller-runtime/pkg/manager" + volumes "sigs.k8s.io/vsphere-csi-driver/v3/pkg/common/cns-lib/volume" + commonconfig "sigs.k8s.io/vsphere-csi-driver/v3/pkg/common/config" + "sigs.k8s.io/vsphere-csi-driver/v3/pkg/csi/service/common" + "sigs.k8s.io/vsphere-csi-driver/v3/pkg/csi/service/common/commonco" + "sigs.k8s.io/vsphere-csi-driver/v3/pkg/csi/service/logger" + k8s "sigs.k8s.io/vsphere-csi-driver/v3/pkg/kubernetes" + "sigs.k8s.io/vsphere-csi-driver/v3/pkg/syncer" + + csivolumeinfosvc "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/csivolumeinfo" + csivolumeinfov1alpha1 "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/csivolumeinfo/v1alpha1" +) + +// Add creates the CsiVolumeInfo controller and registers it with the manager. +// Only runs on Workload (supervisor) clusters with the VMOwnedVolumes FSS enabled. +func Add(mgr manager.Manager, clusterFlavor cnstypes.CnsClusterFlavor, + configInfo *commonconfig.ConfigurationInfo, volumeManager volumes.Manager) error { + ctx, log := logger.GetNewContextWithLogger() + + if clusterFlavor != cnstypes.CnsClusterFlavorWorkload { + log.Debug("Not initializing the CsiVolumeInfo Controller as its a non-WCP CSI deployment") + return nil + } + + coCommonInterface, err := commonco.GetContainerOrchestratorInterface(ctx, + common.Kubernetes, clusterFlavor, &syncer.COInitParams) + if err != nil { + log.Errorf("failed to create CO agnostic interface. Err: %v", err) + return err + } + + if !coCommonInterface.IsFSSEnabled(ctx, common.VMOwnedVolumes) { + log.Infof("Not initializing the CsiVolumeInfo Controller as VMOwnedVolumes feature is disabled") + return nil + } + + // Initialise Kubernetes client (used for scheme registration check and CVI service). + k8sclient, err := k8s.NewClient(ctx) + if err != nil { + log.Errorf("Creating Kubernetes client failed. Err: %v", err) + return err + } + _ = k8sclient // referenced for side-effect: ensures connectivity at startup. + + // Initialise the CsiVolumeInfo service backed by the manager's client so that + // the service shares the same cache-backed client as the reconciler. + cviSvc := csivolumeinfosvc.NewCsiVolumeInfoService(mgr.GetClient()) + + // Register the CsiVolumeInfo type with the manager's scheme. + if err := csivolumeinfov1alpha1.AddToScheme(mgr.GetScheme()); err != nil { + log.Errorf("failed to add CsiVolumeInfo to scheme. Err: %v", err) + return err + } + + return add(mgr, newReconciler(mgr, volumeManager, configInfo, cviSvc)) +} diff --git a/pkg/syncer/cnsoperator/controller/csivolumeinfo/controller.go b/pkg/syncer/cnsoperator/controller/csivolumeinfo/controller.go new file mode 100644 index 0000000000..9ecb64bcaf --- /dev/null +++ b/pkg/syncer/cnsoperator/controller/csivolumeinfo/controller.go @@ -0,0 +1,604 @@ +/* +Copyright 2026 The Kubernetes Authors. + +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 csivolumeinfo + +import ( + "context" + "encoding/json" + "fmt" + "sync" + "time" + + cnstypes "github.com/vmware/govmomi/cns/types" + vim25types "github.com/vmware/govmomi/vim25/types" + corev1 "k8s.io/api/core/v1" + storagev1 "k8s.io/api/storage/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + k8stypes "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/manager" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + csivolumeinfosvc "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/csivolumeinfo" + csivolumeinfov1alpha1 "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/csivolumeinfo/v1alpha1" + volumes "sigs.k8s.io/vsphere-csi-driver/v3/pkg/common/cns-lib/volume" + cnsvsphere "sigs.k8s.io/vsphere-csi-driver/v3/pkg/common/cns-lib/vsphere" + commonconfig "sigs.k8s.io/vsphere-csi-driver/v3/pkg/common/config" + "sigs.k8s.io/vsphere-csi-driver/v3/pkg/csi/service/logger" + cnsoptypes "sigs.k8s.io/vsphere-csi-driver/v3/pkg/syncer/cnsoperator/types" + "sigs.k8s.io/vsphere-csi-driver/v3/pkg/syncer/cnsoperator/util" +) + +const ( + workerThreadEnvVar = "WORKER_THREADS_CSIVOLUMEINFO" + defaultMaxWorkerThreads = 10 + + // conditionTypeReady is the standard condition type used on CsiVolumeInfo. + conditionTypeReady = "Ready" + + // reason strings for conditions. + reasonUnregisterSucceeded = "UnregisterSucceeded" + reasonRegisterSucceeded = "RegisterSucceeded" + reasonReconcileFailed = "ReconcileFailed" + reasonInitialCSIManaged = "InitialCSIManaged" +) + +var ( + // backOffDuration is a map of CsiVolumeInfo names to the next requeue delay. + // Initialised to 1 second and doubled on failure up to MaxBackOffDurationForReconciler. + backOffDuration map[k8stypes.NamespacedName]time.Duration + backOffDurationMapMutex = sync.Mutex{} +) + +// newReconciler returns a new reconcile.Reconciler. +func newReconciler(mgr manager.Manager, volumeManager volumes.Manager, + configInfo *commonconfig.ConfigurationInfo, + cviSvc csivolumeinfosvc.CsiVolumeInfoService) reconcile.Reconciler { + return &Reconciler{ + client: mgr.GetClient(), + scheme: mgr.GetScheme(), + configInfo: configInfo, + volumeManager: volumeManager, + cviSvc: cviSvc, + } +} + +// add registers a new controller with mgr using r as the reconcile.Reconciler. +func add(mgr manager.Manager, r reconcile.Reconciler) error { + ctx, log := logger.GetNewContextWithLogger() + + maxWorkerThreads := util.GetMaxWorkerThreads(ctx, workerThreadEnvVar, defaultMaxWorkerThreads) + + err := ctrl.NewControllerManagedBy(mgr). + Named("csivolumeinfo-controller"). + For(&csivolumeinfov1alpha1.CsiVolumeInfo{}). + WithEventFilter(predicate.GenerationChangedPredicate{}). + WithOptions(controller.Options{MaxConcurrentReconciles: maxWorkerThreads}). + Complete(r) + if err != nil { + log.Errorf("Failed to build csivolumeinfo controller. Err: %v", err) + return err + } + + backOffDuration = make(map[k8stypes.NamespacedName]time.Duration) + return nil +} + +// blank assignment to verify Reconciler implements reconcile.Reconciler. +var _ reconcile.Reconciler = &Reconciler{} + +// Reconciler reconciles CsiVolumeInfo objects. +type Reconciler struct { + // client is a split client: reads from cache, writes to the API server. + client client.Client + scheme *runtime.Scheme + configInfo *commonconfig.ConfigurationInfo + volumeManager volumes.Manager + cviSvc csivolumeinfosvc.CsiVolumeInfoService +} + +// Reconcile reads the state of the CsiVolumeInfo CR and drives the volume +// ownership state machine. The controller is the sole writer of +// status.ownership and status.phase; vm-operator is the sole writer of +// spec.vms. +// +// Decision table: +// +// len(spec.vms)>0 ∧ (ownership=="" || ownership=="CSIManaged") → reconcileUnregister +// len(spec.vms)==0 ∧ ownership=="VMManaged" → reconcileRegister +// otherwise → idle (no-op) +func (r *Reconciler) Reconcile(ctx context.Context, + req reconcile.Request) (reconcile.Result, error) { + ctx = logger.NewContextWithLogger(ctx) + log := logger.GetLogger(ctx).With("name", req.NamespacedName) + log.Infof("Reconcile: entry for CsiVolumeInfo %s", req.Name) + + // CVI CRs always live in vmware-system-csi; look up by the fixed namespace. + cvi := &csivolumeinfov1alpha1.CsiVolumeInfo{} + nn := k8stypes.NamespacedName{ + Namespace: csivolumeinfov1alpha1.CVINamespace, + Name: req.Name, + } + if err := r.client.Get(ctx, nn, cvi); err != nil { + if apierrors.IsNotFound(err) { + // The CVI is gone; drop its backoff entry so the map does not retain + // state for deleted volumes. + deleteBackoffEntry(ctx, nn) + log.Infof("Reconcile: CsiVolumeInfo %s not found; must be deleted — no action", req.Name) + return reconcile.Result{}, nil + } + log.Errorf("Reconcile: error reading CsiVolumeInfo %s: %v", req.Name, err) + return reconcile.Result{}, err + } + + backoff := getBackoffDuration(ctx, nn) + log.Infof("Reconcile: current backoff duration %s", backoff) + + // Ensure the PV ownerRef is present. This is idempotent and repairs CVIs + // created before this logic existed (existing CVIs are reconciled on startup + // via synthetic Create events from the informer cache sync). + if err := r.ensurePVOwnerRef(ctx, cvi); err != nil { + log.Errorf("Reconcile: failed to ensure PV ownerRef for %s: %v", req.Name, err) + return reconcile.Result{}, err + } + + vmCount := len(cvi.Spec.VMs) + ownership := cvi.Status.Ownership + + switch { + case vmCount > 0 && (ownership == "" || ownership == csivolumeinfov1alpha1.OwnershipStateCSIManaged): + log.Infof("Reconcile: %d VM(s) attached, ownership=%q → reconcileUnregister", vmCount, ownership) + if err := r.reconcileUnregister(ctx, cvi); err != nil { + log.Errorf("Reconcile: reconcileUnregister failed: %v", err) + if statusErr := r.setFailedStatus(ctx, cvi, err.Error()); statusErr != nil { + log.Warnf("Reconcile: could not write failed status: %v", statusErr) + } + doubleBackoffDuration(ctx, nn) + return reconcile.Result{RequeueAfter: backoff}, nil + } + updateBackoffEntry(ctx, nn, time.Second) + + case vmCount == 0 && ownership == csivolumeinfov1alpha1.OwnershipStateVMManaged: + log.Infof("Reconcile: no VMs attached, ownership=VMManaged → reconcileRegister") + if err := r.reconcileRegister(ctx, cvi); err != nil { + log.Errorf("Reconcile: reconcileRegister failed: %v", err) + if statusErr := r.setFailedStatus(ctx, cvi, err.Error()); statusErr != nil { + log.Warnf("Reconcile: could not write failed status: %v", statusErr) + } + doubleBackoffDuration(ctx, nn) + return reconcile.Result{RequeueAfter: backoff}, nil + } + updateBackoffEntry(ctx, nn, time.Second) + + case vmCount == 0 && ownership == "": + // Initial state: CR just created, no VMs attached yet. Write the initial + // CSIManaged status so vm-operator's observedGeneration wait condition is satisfied. + log.Infof("Reconcile: initial state — writing CSIManaged status for %s", req.Name) + patch := buildStatusPatch(cvi.Generation, + csivolumeinfov1alpha1.OwnershipStateCSIManaged, + csivolumeinfov1alpha1.PhaseSucceeded, + "", reasonInitialCSIManaged, true) + if err := r.cviSvc.PatchCsiVolumeInfoStatus(ctx, cvi.Spec.VolumeID, patch); err != nil { + log.Errorf("Reconcile: failed to set initial CSIManaged status: %v", err) + return reconcile.Result{}, err + } + + default: + log.Infof("Reconcile: idle — vmCount=%d, ownership=%q; no action", vmCount, ownership) + } + + log.Infof("Reconcile: exit for CsiVolumeInfo %s", req.Name) + return reconcile.Result{}, nil +} + +// reconcileUnregister executes the two-phase CNS unregister protocol and +// transitions the CVI to VMManaged ownership. +// +// Steps: +// 1. Call volumeManager.UnregisterVolumeEx → get backingDiskPath, diskUUID. +// 2. Patch spec.diskPath and spec.diskUUID onto the CVI. +// 3. Add the volume-protection finalizer so GC is blocked while VM-managed. +// 4. Patch status: ownership=VMManaged, phase=Succeeded, observedGeneration, Ready=True. +// 5. Ack the unregister (phase-2 of the two-phase protocol). +func (r *Reconciler) reconcileUnregister(ctx context.Context, + cvi *csivolumeinfov1alpha1.CsiVolumeInfo) error { + log := logger.GetLogger(ctx).With("volumeID", cvi.Spec.VolumeID) + log.Infof("reconcileUnregister: calling UnregisterVolumeEx for volume %q", cvi.Spec.VolumeID) + + backingDiskPath, diskUUID, err := r.volumeManager.UnregisterVolumeEx(ctx, cvi.Spec.VolumeID) + if err != nil { + return fmt.Errorf("reconcileUnregister: UnregisterVolumeEx failed for %q: %w", + cvi.Spec.VolumeID, err) + } + log.Infof("reconcileUnregister: UnregisterVolumeEx succeeded — diskPath=%q, diskUUID=%q", + backingDiskPath, diskUUID) + + // Persist diskPath and diskUUID onto the spec so vm-operator can use them for attachment. + specPatch, err := json.Marshal(map[string]interface{}{ + "spec": map[string]interface{}{ + "diskPath": backingDiskPath, + "diskUUID": diskUUID, + }, + }) + if err != nil { + return fmt.Errorf("reconcileUnregister: failed to marshal spec patch for %q: %w", + cvi.Spec.VolumeID, err) + } + // The spec patch increments metadata.generation, so capture the post-patch + // generation. observedGeneration must reflect this latest generation; + // otherwise vm-operator's green-signal check (observedGeneration >= generation) + // would never be satisfied, because the controller's own spec write advanced + // generation beyond the value observed at reconcile entry. + patchedGen, err := r.cviSvc.PatchCsiVolumeInfo(ctx, cvi.Spec.VolumeID, specPatch) + if err != nil { + return fmt.Errorf("reconcileUnregister: failed to patch spec for %q: %w", + cvi.Spec.VolumeID, err) + } + log.Infof("reconcileUnregister: patched spec.diskPath and spec.diskUUID for volume %q (generation=%d)", + cvi.Spec.VolumeID, patchedGen) + + // Add the volume-protection finalizer before status transition so GC is blocked. + // Finalizer changes are metadata-only and do not advance generation, so + // patchedGen remains the generation to record in status. + if err := r.cviSvc.AddVolumeProtectionFinalizer(ctx, cvi.Spec.VolumeID); err != nil { + return fmt.Errorf("reconcileUnregister: failed to add protection finalizer for %q: %w", + cvi.Spec.VolumeID, err) + } + log.Infof("reconcileUnregister: volume-protection finalizer added for volume %q", cvi.Spec.VolumeID) + + // Write status: ownership=VMManaged, phase=Succeeded. + statusPatch := buildStatusPatch(patchedGen, + csivolumeinfov1alpha1.OwnershipStateVMManaged, + csivolumeinfov1alpha1.PhaseSucceeded, "", reasonUnregisterSucceeded, true) + if err := r.cviSvc.PatchCsiVolumeInfoStatus(ctx, cvi.Spec.VolumeID, statusPatch); err != nil { + return fmt.Errorf("reconcileUnregister: failed to patch status for %q: %w", + cvi.Spec.VolumeID, err) + } + log.Infof("reconcileUnregister: status patched to VMManaged/Succeeded for volume %q", + cvi.Spec.VolumeID) + + // Phase-2: acknowledge the unregister. This must happen after all durable + // state has been written so the controller can recover on restart. + log.Infof("reconcileUnregister: calling AckUnregister for volume %q", cvi.Spec.VolumeID) + if err := r.volumeManager.AckUnregister(ctx, cvi.Spec.VolumeID); err != nil { + return fmt.Errorf("reconcileUnregister: AckUnregister failed for %q: %w", + cvi.Spec.VolumeID, err) + } + log.Infof("reconcileUnregister: AckUnregister completed for volume %q", cvi.Spec.VolumeID) + return nil +} + +// reconcileRegister re-registers a formerly VM-managed VMDK as a first-class +// disk (FCD) with full Kubernetes metadata, and transitions the CVI to +// CSIManaged ownership. +// +// Steps: +// 1. Fetch the PVC and PV referenced by spec.pvcNamespace/pvcName and spec.pvName. +// 2. Reconstruct CNS entity metadata. +// 3. Resolve the SPBM storage policy ID from PV volumeAttributes or StorageClass. +// 4. Call volumeManager.CreateVolume (re-register); CnsVolumeAlreadyExistsFault → success. +// 5. Patch status: ownership=CSIManaged, phase=Succeeded, observedGeneration, Ready=True. +// 6. Remove the volume-protection finalizer. +func (r *Reconciler) reconcileRegister(ctx context.Context, + cvi *csivolumeinfov1alpha1.CsiVolumeInfo) error { + log := logger.GetLogger(ctx).With("volumeID", cvi.Spec.VolumeID) + log.Infof("reconcileRegister: starting for volume %q (diskPath=%q)", cvi.Spec.VolumeID, cvi.Spec.DiskPath) + + // Fetch PVC. + pvc := &corev1.PersistentVolumeClaim{} + if err := r.client.Get(ctx, k8stypes.NamespacedName{ + Namespace: cvi.Spec.PVCNamespace, + Name: cvi.Spec.PVCName, + }, pvc); err != nil { + return fmt.Errorf("reconcileRegister: failed to get PVC %s/%s: %w", + cvi.Spec.PVCNamespace, cvi.Spec.PVCName, err) + } + + // Fetch PV. + pv := &corev1.PersistentVolume{} + if err := r.client.Get(ctx, k8stypes.NamespacedName{Name: cvi.Spec.PVName}, pv); err != nil { + return fmt.Errorf("reconcileRegister: failed to get PV %q: %w", cvi.Spec.PVName, err) + } + + // Resolve cluster ID. + clusterID := r.configInfo.Cfg.Global.ClusterID + if clusterID == "" { + clusterID = r.configInfo.Cfg.Global.SupervisorID + } + + // Extract vCenter user for containerCluster. + var vcUser, clusterDist string + for _, vcCfg := range r.configInfo.Cfg.VirtualCenter { + vcUser = vcCfg.User + break + } + clusterDist = r.configInfo.Cfg.Global.ClusterDistribution + + containerCluster := cnsvsphere.GetContainerCluster( + clusterID, vcUser, cnstypes.CnsClusterFlavorWorkload, clusterDist) + + // Reconstruct entity references from the live PVC and PV. + pvRef := cnsvsphere.CreateCnsKuberenetesEntityReference( + string(cnstypes.CnsKubernetesEntityTypePV), + pv.Name, "", clusterID) + + pvcMeta := cnsvsphere.GetCnsKubernetesEntityMetaData( + pvc.Name, pvc.Labels, false, + string(cnstypes.CnsKubernetesEntityTypePVC), + pvc.Namespace, clusterID, + []cnstypes.CnsKubernetesEntityReference{pvRef}) + + pvMeta := cnsvsphere.GetCnsKubernetesEntityMetaData( + pv.Name, pv.Labels, false, + string(cnstypes.CnsKubernetesEntityTypePV), + "", clusterID, nil) + + // Resolve the SPBM profile ID. + storagePolicyID := resolveStoragePolicyID(ctx, r.client, pv) + + // Convert the datastore path captured during unregister to the HTTP folder + // URL that CNS's RegisterVMDKWithUrlAction requires. BackingDiskId cannot be + // used here because the FCD entry no longer exists after UnregisterVolumeEx. + diskFolderURL, err := r.volumeManager.GetDiskFolderURL(ctx, cvi.Spec.DiskPath) + if err != nil { + return fmt.Errorf("reconcileRegister: failed to resolve disk folder URL for %q: %w", + cvi.Spec.DiskPath, err) + } + + // Preserve the original FCD ID by setting VolumeId on the top-level spec. + // When FCD_TRANSACTION_SUPPORT is enabled (vSphere 9.2+) CNS passes this to + // FcdSvc()->RegisterDisk so the re-registered FCD retains the same UUID. + origVolumeID := cnstypes.CnsVolumeId{Id: cvi.Spec.VolumeID} + createSpec := &cnstypes.CnsVolumeCreateSpec{ + Name: pv.Name, + VolumeType: string(cnstypes.CnsVolumeTypeBlock), + VolumeId: &origVolumeID, + Profile: buildStorageProfileSpec(storagePolicyID), + Metadata: cnstypes.CnsVolumeMetadata{ + ContainerCluster: containerCluster, + ContainerClusterArray: []cnstypes.CnsContainerCluster{containerCluster}, + EntityMetadata: []cnstypes.BaseCnsEntityMetadata{pvcMeta, pvMeta}, + }, + BackingObjectDetails: &cnstypes.CnsBlockBackingDetails{ + BackingDiskUrlPath: diskFolderURL, + }, + } + + log.Infof("reconcileRegister: calling CreateVolume for volume %q", cvi.Spec.VolumeID) + _, faultType, err := r.volumeManager.CreateVolume(ctx, createSpec, nil) + if err != nil { + // An already-registered backing disk means the volume is back under CSI + // management, which is the desired end state — treat it as success. CNS + // reports this as either CnsAlreadyRegisteredFault (re-register) or + // CnsVolumeAlreadyExistsFault. + if volumes.IsCnsAlreadyRegisteredFault(ctx, faultType) || + volumes.IsCnsVolumeAlreadyExistsFault(ctx, faultType) { + log.Infof("reconcileRegister: volume %q already registered as FCD — treating as success", + cvi.Spec.VolumeID) + } else { + return fmt.Errorf("reconcileRegister: CreateVolume failed for %q (fault=%q): %w", + cvi.Spec.VolumeID, faultType, err) + } + } else { + log.Infof("reconcileRegister: CreateVolume succeeded for volume %q", cvi.Spec.VolumeID) + } + + // Patch status: ownership=CSIManaged, phase=Succeeded. + statusPatch := buildStatusPatch(cvi.Generation, + csivolumeinfov1alpha1.OwnershipStateCSIManaged, + csivolumeinfov1alpha1.PhaseSucceeded, "", reasonRegisterSucceeded, true) + if err := r.cviSvc.PatchCsiVolumeInfoStatus(ctx, cvi.Spec.VolumeID, statusPatch); err != nil { + return fmt.Errorf("reconcileRegister: failed to patch status for %q: %w", + cvi.Spec.VolumeID, err) + } + log.Infof("reconcileRegister: status patched to CSIManaged/Succeeded for volume %q", cvi.Spec.VolumeID) + + // Remove the volume-protection finalizer now the volume is CSI-managed again. + if err := r.cviSvc.RemoveVolumeProtectionFinalizer(ctx, cvi.Spec.VolumeID); err != nil { + return fmt.Errorf("reconcileRegister: failed to remove protection finalizer for %q: %w", + cvi.Spec.VolumeID, err) + } + log.Infof("reconcileRegister: volume-protection finalizer removed for volume %q", cvi.Spec.VolumeID) + return nil +} + +// ensurePVOwnerRef sets a PersistentVolume ownerReference on the CsiVolumeInfo +// if one is not already present. This enables Kubernetes GC to cascade-delete +// the CVI when the PV is deleted (CSIManaged path) and blockOwnerDeletion to +// prevent PV deletion while the volume-protection finalizer is present (VMManaged path). +// +// If the PV does not exist yet (race between CreateVolume and PV creation by the CO), +// the error is returned so controller-runtime requeues the reconcile. +func (r *Reconciler) ensurePVOwnerRef(ctx context.Context, + cvi *csivolumeinfov1alpha1.CsiVolumeInfo) error { + log := logger.GetLogger(ctx).With("volumeID", cvi.Spec.VolumeID) + + if cvi.Spec.PVName == "" { + log.Debugf("ensurePVOwnerRef: spec.pvName empty, skipping") + return nil + } + + // Already set — nothing to do. + for _, ref := range cvi.OwnerReferences { + if ref.Kind == "PersistentVolume" && ref.Name == cvi.Spec.PVName { + return nil + } + } + + pv := &corev1.PersistentVolume{} + if err := r.client.Get(ctx, k8stypes.NamespacedName{Name: cvi.Spec.PVName}, pv); err != nil { + if apierrors.IsNotFound(err) { + // PV not yet created by the CO; requeue so we retry once it exists. + return fmt.Errorf("ensurePVOwnerRef: PV %q not found yet for CVI %q, will retry", + cvi.Spec.PVName, cvi.Name) + } + return fmt.Errorf("ensurePVOwnerRef: failed to get PV %q: %w", cvi.Spec.PVName, err) + } + + blockOwnerDeletion := true + ownerRef := metav1.OwnerReference{ + APIVersion: "v1", + Kind: "PersistentVolume", + Name: pv.Name, + UID: pv.UID, + BlockOwnerDeletion: &blockOwnerDeletion, + } + + patch := map[string]interface{}{ + "metadata": map[string]interface{}{ + "ownerReferences": []metav1.OwnerReference{ownerRef}, + }, + } + patchBytes, err := json.Marshal(patch) + if err != nil { + return fmt.Errorf("ensurePVOwnerRef: failed to marshal patch: %w", err) + } + + if err := r.client.Patch(ctx, cvi, client.RawPatch(k8stypes.MergePatchType, patchBytes)); err != nil { + return fmt.Errorf("ensurePVOwnerRef: failed to patch CVI %q: %w", cvi.Name, err) + } + + log.Infof("ensurePVOwnerRef: set PV ownerRef (pv=%q uid=%q) on CVI %q", + pv.Name, pv.UID, cvi.Name) + return nil +} + +// setFailedStatus patches status.phase=Failed with an error message and +// sets the Ready condition to False. observedGeneration is also updated. +func (r *Reconciler) setFailedStatus(ctx context.Context, + cvi *csivolumeinfov1alpha1.CsiVolumeInfo, errMsg string) error { + patch := buildStatusPatch(cvi.Generation, + cvi.Status.Ownership, // do not change ownership on failure + csivolumeinfov1alpha1.PhaseFailed, + errMsg, reasonReconcileFailed, false) + return r.cviSvc.PatchCsiVolumeInfoStatus(ctx, cvi.Spec.VolumeID, patch) +} + +// buildStatusPatch constructs a JSON merge-patch for the status subresource. +// It always sets observedGeneration to ensure vm-operator's wait condition is met. +func buildStatusPatch(generation int64, ownership csivolumeinfov1alpha1.OwnershipState, + phase csivolumeinfov1alpha1.PhaseState, errMsg string, + condReason string, condReady bool) []byte { + + condStatus := metav1.ConditionFalse + if condReady { + condStatus = metav1.ConditionTrue + } + cond := map[string]interface{}{ + "type": conditionTypeReady, + "status": string(condStatus), + "reason": condReason, + "message": errMsg, + "lastTransitionTime": metav1.Now().UTC().Format(time.RFC3339), + } + statusMap := map[string]interface{}{ + "status": map[string]interface{}{ + "ownership": string(ownership), + "phase": string(phase), + "observedGeneration": generation, + "error": errMsg, + "conditions": []interface{}{cond}, + }, + } + b, _ := json.Marshal(statusMap) + return b +} + +// resolveStoragePolicyID extracts the SPBM storage policy ID from the PV. +// It first tries pv.spec.csi.volumeAttributes["storagePolicyID"], then falls +// back to the StorageClass parameters["storagePolicyID"] if not found. +func resolveStoragePolicyID(ctx context.Context, c client.Client, pv *corev1.PersistentVolume) string { + log := logger.GetLogger(ctx).With("pvName", pv.Name) + + if pv.Spec.CSI != nil { + if id, ok := pv.Spec.CSI.VolumeAttributes["storagePolicyID"]; ok && id != "" { + log.Infof("resolveStoragePolicyID: found storagePolicyID %q in PV volumeAttributes", id) + return id + } + } + + // Fall back to the StorageClass parameters. + scName := pv.Spec.StorageClassName + if scName == "" { + log.Warnf("resolveStoragePolicyID: PV has no storageClassName; cannot resolve policy ID") + return "" + } + + sc := &storagev1.StorageClass{} + if err := c.Get(ctx, k8stypes.NamespacedName{Name: scName}, sc); err != nil { + log.Warnf("resolveStoragePolicyID: failed to get StorageClass %q: %v", scName, err) + return "" + } + + if id, ok := sc.Parameters["storagePolicyID"]; ok && id != "" { + log.Infof("resolveStoragePolicyID: found storagePolicyID %q in StorageClass %q", id, scName) + return id + } + + log.Warnf("resolveStoragePolicyID: storagePolicyID not found in PV or StorageClass %q", scName) + return "" +} + +// buildStorageProfileSpec returns the CNS storage profile spec for the given policy ID. +// Returns nil if the policy ID is empty. +func buildStorageProfileSpec(storagePolicyID string) []vim25types.BaseVirtualMachineProfileSpec { + if storagePolicyID == "" { + return nil + } + return []vim25types.BaseVirtualMachineProfileSpec{ + &vim25types.VirtualMachineDefinedProfileSpec{ + ProfileId: storagePolicyID, + }, + } +} + +// getBackoffDuration returns the current backoff for the given name, +// initialising it to 1 second if absent. +func getBackoffDuration(ctx context.Context, name k8stypes.NamespacedName) time.Duration { + backOffDurationMapMutex.Lock() + defer backOffDurationMapMutex.Unlock() + if _, exists := backOffDuration[name]; !exists { + backOffDuration[name] = time.Second + } + return backOffDuration[name] +} + +// doubleBackoffDuration doubles the backoff up to MaxBackOffDurationForReconciler. +func doubleBackoffDuration(ctx context.Context, name k8stypes.NamespacedName) { + d := getBackoffDuration(ctx, name) + d = min(d*2, cnsoptypes.MaxBackOffDurationForReconciler) + updateBackoffEntry(ctx, name, d) +} + +// updateBackoffEntry sets the backoff for the given name. +func updateBackoffEntry(ctx context.Context, name k8stypes.NamespacedName, duration time.Duration) { + backOffDurationMapMutex.Lock() + defer backOffDurationMapMutex.Unlock() + backOffDuration[name] = duration +} + +// deleteBackoffEntry removes the backoff entry for the given name. +func deleteBackoffEntry(ctx context.Context, name k8stypes.NamespacedName) { + backOffDurationMapMutex.Lock() + defer backOffDurationMapMutex.Unlock() + delete(backOffDuration, name) +} diff --git a/pkg/syncer/cnsoperator/controller/csivolumeinfo/controller_test.go b/pkg/syncer/cnsoperator/controller/csivolumeinfo/controller_test.go new file mode 100644 index 0000000000..0da9e87e8d --- /dev/null +++ b/pkg/syncer/cnsoperator/controller/csivolumeinfo/controller_test.go @@ -0,0 +1,1139 @@ +/* +Copyright 2026 The Kubernetes Authors. + +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 csivolumeinfo + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + cnstypes "github.com/vmware/govmomi/cns/types" + "github.com/vmware/govmomi/object" + vim25types "github.com/vmware/govmomi/vim25/types" + corev1 "k8s.io/api/core/v1" + storagev1 "k8s.io/api/storage/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + k8stypes "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + csivolumeinfosvc "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/csivolumeinfo" + csivolumeinfov1alpha1 "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/csivolumeinfo/v1alpha1" + cnsvolumes "sigs.k8s.io/vsphere-csi-driver/v3/pkg/common/cns-lib/volume" + cnsvsphere "sigs.k8s.io/vsphere-csi-driver/v3/pkg/common/cns-lib/vsphere" + commonconfig "sigs.k8s.io/vsphere-csi-driver/v3/pkg/common/config" + cnsvolumeoperationrequest "sigs.k8s.io/vsphere-csi-driver/v3/pkg/internalapis/cnsvolumeoperationrequest" +) + +// --------------------------------------------------------------------------- +// fakeCsiVolumeInfoService — in-process service backed by a fake client. +// --------------------------------------------------------------------------- + +// fakeCsiVolumeInfoService wraps a controller-runtime fake client and provides +// the CsiVolumeInfoService interface for tests. Its methods are thin delegates +// to the concrete service implementation so we exercise real service logic. +type fakeCsiVolumeInfoService struct { + inner csivolumeinfosvc.CsiVolumeInfoService +} + +func newFakeCviService(c client.Client) csivolumeinfosvc.CsiVolumeInfoService { + return &fakeCsiVolumeInfoService{inner: csivolumeinfosvc.NewCsiVolumeInfoService(c)} +} + +func (f *fakeCsiVolumeInfoService) CreateCsiVolumeInfo( + ctx context.Context, cvi *csivolumeinfov1alpha1.CsiVolumeInfo) error { + return f.inner.CreateCsiVolumeInfo(ctx, cvi) +} + +func (f *fakeCsiVolumeInfoService) GetCsiVolumeInfo( + ctx context.Context, volumeID string) (*csivolumeinfov1alpha1.CsiVolumeInfo, error) { + return f.inner.GetCsiVolumeInfo(ctx, volumeID) +} + +func (f *fakeCsiVolumeInfoService) UpdateCsiVolumeInfoStatus( + ctx context.Context, cvi *csivolumeinfov1alpha1.CsiVolumeInfo) error { + return f.inner.UpdateCsiVolumeInfoStatus(ctx, cvi) +} + +func (f *fakeCsiVolumeInfoService) PatchCsiVolumeInfo( + ctx context.Context, volumeID string, patchBytes []byte) (int64, error) { + return f.inner.PatchCsiVolumeInfo(ctx, volumeID, patchBytes) +} + +func (f *fakeCsiVolumeInfoService) PatchCsiVolumeInfoStatus( + ctx context.Context, volumeID string, patchBytes []byte) error { + return f.inner.PatchCsiVolumeInfoStatus(ctx, volumeID, patchBytes) +} + +func (f *fakeCsiVolumeInfoService) DeleteCsiVolumeInfo( + ctx context.Context, volumeID string) error { + return f.inner.DeleteCsiVolumeInfo(ctx, volumeID) +} + +func (f *fakeCsiVolumeInfoService) CsiVolumeInfoExists( + ctx context.Context, volumeID string) (bool, error) { + return f.inner.CsiVolumeInfoExists(ctx, volumeID) +} + +func (f *fakeCsiVolumeInfoService) AddVolumeProtectionFinalizer( + ctx context.Context, volumeID string) error { + return f.inner.AddVolumeProtectionFinalizer(ctx, volumeID) +} + +func (f *fakeCsiVolumeInfoService) RemoveVolumeProtectionFinalizer( + ctx context.Context, volumeID string) error { + return f.inner.RemoveVolumeProtectionFinalizer(ctx, volumeID) +} + +// genBumpingCviService simulates the API server incrementing metadata.generation +// on a spec patch. It applies the patch through the wrapped service but reports a +// higher generation than the fake store actually records (the controller-runtime +// fake client does not bump generation on a patch). This lets a test verify that +// the reconciler records observedGeneration from the patch result rather than from +// the generation observed at reconcile entry. +type genBumpingCviService struct { + csivolumeinfosvc.CsiVolumeInfoService + bumpedGen int64 +} + +func (g *genBumpingCviService) PatchCsiVolumeInfo( + ctx context.Context, volumeID string, patchBytes []byte) (int64, error) { + if _, err := g.CsiVolumeInfoService.PatchCsiVolumeInfo(ctx, volumeID, patchBytes); err != nil { + return 0, err + } + return g.bumpedGen, nil +} + +// --------------------------------------------------------------------------- +// testVolumeManager — fully configurable mock for the Manager interface. +// --------------------------------------------------------------------------- + +// testVolumeManager is a test-scoped implementation of volumes.Manager that +// lets each test control the behaviour of UnregisterVolumeEx, AckUnregister, +// and CreateVolume independently. +type testVolumeManager struct { + unregisterVolumeExFn func(ctx context.Context, volumeID string) (string, string, error) + ackUnregisterFn func(ctx context.Context, volumeID string) error + createVolumeFn func(ctx context.Context, spec *cnstypes.CnsVolumeCreateSpec, + extraParams interface{}) (*cnsvolumes.CnsVolumeInfo, string, error) +} + +func (m *testVolumeManager) UnregisterVolumeEx(ctx context.Context, volumeID string) (string, string, error) { + if m.unregisterVolumeExFn != nil { + return m.unregisterVolumeExFn(ctx, volumeID) + } + return "test-disk-path", "test-disk-uuid", nil +} + +func (m *testVolumeManager) AckUnregister(ctx context.Context, volumeID string) error { + if m.ackUnregisterFn != nil { + return m.ackUnregisterFn(ctx, volumeID) + } + return nil +} + +func (m *testVolumeManager) GetDiskFolderURL(ctx context.Context, datastorePath string) (string, error) { + return "", nil +} + +func (m *testVolumeManager) CreateVolume(ctx context.Context, spec *cnstypes.CnsVolumeCreateSpec, + extraParams interface{}) (*cnsvolumes.CnsVolumeInfo, string, error) { + if m.createVolumeFn != nil { + return m.createVolumeFn(ctx, spec, extraParams) + } + return &cnsvolumes.CnsVolumeInfo{}, "", nil +} + +// Remaining Manager interface methods — not exercised by these tests. +func (m *testVolumeManager) AttachVolume(ctx context.Context, vm *cnsvsphere.VirtualMachine, + volumeID string, checkNVMeController bool) (string, string, error) { + panic("not implemented") +} +func (m *testVolumeManager) DetachVolume(ctx context.Context, vm *cnsvsphere.VirtualMachine, + volumeID string) (string, error) { + panic("not implemented") +} +func (m *testVolumeManager) DeleteVolume(ctx context.Context, volumeID string, + deleteDisk bool) (string, error) { + panic("not implemented") +} +func (m *testVolumeManager) UpdateVolumeMetadata(ctx context.Context, + spec *cnstypes.CnsVolumeMetadataUpdateSpec) error { + panic("not implemented") +} +func (m *testVolumeManager) UpdateVolumeCrypto(ctx context.Context, + spec *cnstypes.CnsVolumeCryptoUpdateSpec) error { + panic("not implemented") +} +func (m *testVolumeManager) QueryVolumeInfo(ctx context.Context, + volumeIDList []cnstypes.CnsVolumeId) (*cnstypes.CnsQueryVolumeInfoResult, error) { + panic("not implemented") +} +func (m *testVolumeManager) QueryAllVolume(ctx context.Context, queryFilter cnstypes.CnsQueryFilter, + querySelection cnstypes.CnsQuerySelection) (*cnstypes.CnsQueryResult, error) { + panic("not implemented") +} +func (m *testVolumeManager) QueryVolumeAsync(ctx context.Context, queryFilter cnstypes.CnsQueryFilter, + querySelection *cnstypes.CnsQuerySelection) (*cnstypes.CnsQueryResult, error) { + panic("not implemented") +} +func (m *testVolumeManager) QueryVolume(ctx context.Context, + queryFilter cnstypes.CnsQueryFilter) (*cnstypes.CnsQueryResult, error) { + panic("not implemented") +} +func (m *testVolumeManager) RelocateVolume(ctx context.Context, + relocateSpecList ...cnstypes.BaseCnsVolumeRelocateSpec) (*object.Task, error) { + panic("not implemented") +} +func (m *testVolumeManager) ExpandVolume(ctx context.Context, volumeID string, size int64, + extraParams interface{}) (string, error) { + panic("not implemented") +} +func (m *testVolumeManager) ResetManager(ctx context.Context, vcenter *cnsvsphere.VirtualCenter) error { + panic("not implemented") +} +func (m *testVolumeManager) ConfigureVolumeACLs(ctx context.Context, + spec cnstypes.CnsVolumeACLConfigureSpec) error { + panic("not implemented") +} +func (m *testVolumeManager) RegisterDisk(ctx context.Context, path string, name string) (string, error) { + panic("not implemented") +} +func (m *testVolumeManager) RetrieveVStorageObject(ctx context.Context, + volumeID string) (*vim25types.VStorageObject, error) { + panic("not implemented") +} +func (m *testVolumeManager) ProtectVolumeFromVMDeletion(ctx context.Context, volumeID string) error { + panic("not implemented") +} +func (m *testVolumeManager) UnprotectVolumeFromVMDeletion(ctx context.Context, volumeID string) error { + panic("not implemented") +} +func (m *testVolumeManager) SetVolumeControlFlags(ctx context.Context, volumeID string, + controlFlags []string) error { + panic("not implemented") +} +func (m *testVolumeManager) ClearVolumeControlFlags(ctx context.Context, volumeID string, + controlFlags []string) error { + panic("not implemented") +} +func (m *testVolumeManager) CreateSnapshot(ctx context.Context, volumeID string, desc string, + extraParams interface{}) (*cnsvolumes.CnsSnapshotInfo, error) { + panic("not implemented") +} +func (m *testVolumeManager) DeleteSnapshot(ctx context.Context, volumeID string, snapshotID string, + extraParams interface{}) (*cnsvolumes.CnsSnapshotInfo, error) { + panic("not implemented") +} +func (m *testVolumeManager) QuerySnapshots(ctx context.Context, + snapshotQueryFilter cnstypes.CnsSnapshotQueryFilter) (*cnstypes.CnsSnapshotQueryResult, error) { + panic("not implemented") +} +func (m *testVolumeManager) MonitorCreateVolumeTask(ctx context.Context, + details **cnsvolumeoperationrequest.VolumeOperationRequestDetails, task *object.Task, + volNameFromInputSpec, clusterID string) (*cnsvolumes.CnsVolumeInfo, string, error) { + panic("not implemented") +} +func (m *testVolumeManager) GetOperationStore() cnsvolumeoperationrequest.VolumeOperationRequest { + panic("not implemented") +} +func (m *testVolumeManager) IsListViewReady() bool { return true } +func (m *testVolumeManager) SetListViewNotReady(ctx context.Context) {} +func (m *testVolumeManager) BatchAttachVolumes(ctx context.Context, vm *cnsvsphere.VirtualMachine, + batchAttachRequest []cnsvolumes.BatchAttachRequest) ([]cnsvolumes.BatchAttachResult, string, error) { + panic("not implemented") +} +func (m *testVolumeManager) UnregisterVolume(ctx context.Context, volumeID string, + unregisterDisk bool) (string, error) { + panic("not implemented") +} +func (m *testVolumeManager) SyncVolume(ctx context.Context, + syncVolumeSpecs []cnstypes.CnsSyncVolumeSpec) (string, error) { + panic("not implemented") +} +func (m *testVolumeManager) ReRegisterVolume(ctx context.Context, volumeID string) error { + panic("not implemented") +} +func (m *testVolumeManager) QueryFCDAllocatedBlocks(ctx context.Context, + volumeID, snapshotID string, startingOffset uint64) ( + []cnsvolumes.DiskArea, uint64, error) { + panic("not implemented") +} +func (m *testVolumeManager) QueryFCDChangedBlocks(ctx context.Context, + volumeID, targetSnapshotID, baseChangeID string, startingOffset uint64) ( + []cnsvolumes.DiskArea, uint64, error) { + panic("not implemented") +} +func (m *testVolumeManager) QueryPendingUnregisters(ctx context.Context) ( + []cnsvolumes.PendingUnregisterRecord, error) { + return nil, nil +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +func newScheme(t *testing.T) *runtime.Scheme { + t.Helper() + s := runtime.NewScheme() + require.NoError(t, csivolumeinfov1alpha1.AddToScheme(s)) + require.NoError(t, corev1.AddToScheme(s)) + require.NoError(t, storagev1.AddToScheme(s)) + return s +} + +func newFakeClient(t *testing.T, s *runtime.Scheme, objs []client.Object, + ifuncs interceptor.Funcs) client.Client { + t.Helper() + cb := fake.NewClientBuilder(). + WithScheme(s). + WithObjects(objs...). + WithStatusSubresource(&csivolumeinfov1alpha1.CsiVolumeInfo{}). + WithInterceptorFuncs(ifuncs) + return cb.Build() +} + +func minimalConfigInfo() *commonconfig.ConfigurationInfo { + cfg := &commonconfig.Config{ + VirtualCenter: map[string]*commonconfig.VirtualCenterConfig{ + "vcenter": {User: "test-user"}, + }, + } + cfg.Global.ClusterID = "test-cluster" + return &commonconfig.ConfigurationInfo{Cfg: cfg} +} + +// newCVI creates a CsiVolumeInfo for testing. The object is created in the +// vmware-system-csi namespace which is the fixed namespace for all CVIs. +func newCVI(volumeID string, vms []csivolumeinfov1alpha1.VirtualMachineRef, + ownership csivolumeinfov1alpha1.OwnershipState, + diskPath, diskUUID string, + generation int64, + finalizers []string) *csivolumeinfov1alpha1.CsiVolumeInfo { + cvi := &csivolumeinfov1alpha1.CsiVolumeInfo{ + ObjectMeta: metav1.ObjectMeta{ + Name: csivolumeinfosvc.GetCsiVolumeInfoCRName(volumeID), + Namespace: csivolumeinfov1alpha1.CVINamespace, + Generation: generation, + Finalizers: finalizers, + }, + Spec: csivolumeinfov1alpha1.CsiVolumeInfoSpec{ + VolumeID: volumeID, + PVCName: "test-pvc", + PVCNamespace: "test-ns", + PVName: "test-pv", + DiskPath: diskPath, + DiskUUID: diskUUID, + VMs: vms, + }, + Status: csivolumeinfov1alpha1.CsiVolumeInfoStatus{ + Ownership: ownership, + }, + } + return cvi +} + +func makeRequest(volumeID string) reconcile.Request { + return reconcile.Request{ + NamespacedName: k8stypes.NamespacedName{ + Namespace: csivolumeinfov1alpha1.CVINamespace, + Name: csivolumeinfosvc.GetCsiVolumeInfoCRName(volumeID), + }, + } +} + +func newTestPV(name string) *corev1.PersistentVolume { + return &corev1.PersistentVolume{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Spec: corev1.PersistentVolumeSpec{ + StorageClassName: "test-sc", + PersistentVolumeSource: corev1.PersistentVolumeSource{ + CSI: &corev1.CSIPersistentVolumeSource{ + Driver: "csi.vsphere.volume", + VolumeHandle: "test-volume-id", + VolumeAttributes: map[string]string{"storagePolicyID": "test-policy"}, + }, + }, + }, + } +} + +func newTestPVC(name, ns string) *corev1.PersistentVolumeClaim { + return &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns}, + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +// TestReconcile_NotFound verifies that a missing CVI is silently ignored +// (no requeue, no error). +func TestReconcile_NotFound(t *testing.T) { + s := newScheme(t) + c := newFakeClient(t, s, nil, interceptor.Funcs{ + Get: func(ctx context.Context, cl client.WithWatch, key client.ObjectKey, + obj client.Object, opts ...client.GetOption) error { + return apierrors.NewNotFound(schema.GroupResource{ + Group: csivolumeinfov1alpha1.GroupName, + Resource: csivolumeinfov1alpha1.CRDSingular, + }, key.Name) + }, + }) + r := &Reconciler{ + client: c, + scheme: s, + configInfo: minimalConfigInfo(), + volumeManager: &testVolumeManager{}, + cviSvc: newFakeCviService(c), + } + backOffDuration = make(map[k8stypes.NamespacedName]time.Duration) + + res, err := r.Reconcile(context.Background(), makeRequest("vol-001")) + assert.NoError(t, err) + assert.True(t, res.IsZero()) +} + +// TestReconcile_GetError verifies that a transient Get error is returned so the +// controller-runtime framework requeues it. +func TestReconcile_GetError(t *testing.T) { + s := newScheme(t) + c := newFakeClient(t, s, nil, interceptor.Funcs{ + Get: func(ctx context.Context, cl client.WithWatch, key client.ObjectKey, + obj client.Object, opts ...client.GetOption) error { + return errors.New("transient error") + }, + }) + r := &Reconciler{ + client: c, + scheme: s, + configInfo: minimalConfigInfo(), + volumeManager: &testVolumeManager{}, + cviSvc: newFakeCviService(c), + } + backOffDuration = make(map[k8stypes.NamespacedName]time.Duration) + + _, err := r.Reconcile(context.Background(), makeRequest("vol-001")) + assert.Error(t, err) +} + +// TestReconcile_UnregisterTransition verifies the full unregister path: +// spec.vms=[vmA], ownership="" → UnregisterVolumeEx called, spec.diskPath/diskUUID +// patched, protection finalizer added, status.ownership=VMManaged, phase=Succeeded, +// observedGeneration matches, AckUnregister called. +func TestReconcile_UnregisterTransition(t *testing.T) { + const volID = "vol-unregister" + vms := []csivolumeinfov1alpha1.VirtualMachineRef{{VMName: "vm-a"}} + cvi := newCVI(volID, vms, "", "", "", 3, nil) + + s := newScheme(t) + c := newFakeClient(t, s, []client.Object{cvi, newTestPV("test-pv")}, interceptor.Funcs{}) + + ackCalled := false + mgr := &testVolumeManager{ + unregisterVolumeExFn: func(_ context.Context, id string) (string, string, error) { + assert.Equal(t, volID, id) + return "/vmfs/volumes/ds/disk.vmdk", "disk-uuid-123", nil + }, + ackUnregisterFn: func(_ context.Context, id string) error { + assert.Equal(t, volID, id) + ackCalled = true + return nil + }, + } + + r := &Reconciler{ + client: c, + scheme: s, + configInfo: minimalConfigInfo(), + volumeManager: mgr, + cviSvc: newFakeCviService(c), + } + backOffDuration = make(map[k8stypes.NamespacedName]time.Duration) + + res, err := r.Reconcile(context.Background(), makeRequest(volID)) + require.NoError(t, err) + assert.True(t, res.IsZero()) + assert.True(t, ackCalled, "AckUnregister must be called") + + // Read updated CVI from fake store. + updated := &csivolumeinfov1alpha1.CsiVolumeInfo{} + require.NoError(t, c.Get(context.Background(), k8stypes.NamespacedName{ + Namespace: csivolumeinfov1alpha1.CVINamespace, + Name: csivolumeinfosvc.GetCsiVolumeInfoCRName(volID), + }, updated)) + + assert.Equal(t, "/vmfs/volumes/ds/disk.vmdk", updated.Spec.DiskPath) + assert.Equal(t, "disk-uuid-123", updated.Spec.DiskUUID) + assert.Contains(t, updated.Finalizers, csivolumeinfov1alpha1.VolumeProtectionFinalizer) + assert.Equal(t, csivolumeinfov1alpha1.OwnershipStateVMManaged, updated.Status.Ownership) + assert.Equal(t, csivolumeinfov1alpha1.PhaseSucceeded, updated.Status.Phase) + // observedGeneration must match the live generation (including any bump from the + // controller's own diskPath/diskUUID spec write) so the green signal is satisfiable. + assert.Equal(t, updated.Generation, updated.Status.ObservedGeneration) + assert.Empty(t, updated.Status.Error) +} + +// TestReconcile_UnregisterObservedGenerationTracksSpecWrite verifies that the +// unregister path records observedGeneration from the generation returned by the +// spec patch (which the API server bumps), not the generation observed at reconcile +// entry. Without this, the controller's own diskPath/diskUUID write would leave +// observedGeneration permanently behind generation and the green signal would never +// be satisfied. +func TestReconcile_UnregisterObservedGenerationTracksSpecWrite(t *testing.T) { + const volID = "vol-gen-track" + const bumpedGen = int64(5) + vms := []csivolumeinfov1alpha1.VirtualMachineRef{{VMName: "vm-a"}} + // Entry generation is 4; the simulated spec write reports generation 5. + cvi := newCVI(volID, vms, "", "", "", 4, nil) + + s := newScheme(t) + c := newFakeClient(t, s, []client.Object{cvi, newTestPV("test-pv")}, interceptor.Funcs{}) + + r := &Reconciler{ + client: c, + scheme: s, + configInfo: minimalConfigInfo(), + volumeManager: &testVolumeManager{}, + cviSvc: &genBumpingCviService{ + CsiVolumeInfoService: newFakeCviService(c), + bumpedGen: bumpedGen, + }, + } + backOffDuration = make(map[k8stypes.NamespacedName]time.Duration) + + res, err := r.Reconcile(context.Background(), makeRequest(volID)) + require.NoError(t, err) + assert.True(t, res.IsZero()) + + updated := &csivolumeinfov1alpha1.CsiVolumeInfo{} + require.NoError(t, c.Get(context.Background(), k8stypes.NamespacedName{ + Namespace: csivolumeinfov1alpha1.CVINamespace, + Name: csivolumeinfosvc.GetCsiVolumeInfoCRName(volID), + }, updated)) + assert.Equal(t, bumpedGen, updated.Status.ObservedGeneration, + "observedGeneration must follow the post-spec-write generation") +} + +// TestReconcile_BrownfieldLazy verifies that a CVI with spec.vms set but +// ownership="" (brownfield/lazy) is treated identically to CSIManaged and +// triggers reconcileUnregister. +func TestReconcile_BrownfieldLazy(t *testing.T) { + const volID = "vol-brownfield" + vms := []csivolumeinfov1alpha1.VirtualMachineRef{{VMName: "vm-a"}} + // ownership="" is the brownfield case + cvi := newCVI(volID, vms, "", "", "", 1, nil) + + s := newScheme(t) + c := newFakeClient(t, s, []client.Object{cvi, newTestPV("test-pv")}, interceptor.Funcs{}) + + called := false + mgr := &testVolumeManager{ + unregisterVolumeExFn: func(_ context.Context, _ string) (string, string, error) { + called = true + return "path", "uuid", nil + }, + } + + r := &Reconciler{ + client: c, + scheme: s, + configInfo: minimalConfigInfo(), + volumeManager: mgr, + cviSvc: newFakeCviService(c), + } + backOffDuration = make(map[k8stypes.NamespacedName]time.Duration) + + res, err := r.Reconcile(context.Background(), makeRequest(volID)) + require.NoError(t, err) + assert.True(t, res.IsZero()) + assert.True(t, called, "UnregisterVolumeEx must be called for brownfield-lazy CVI") +} + +// TestReconcile_RegisterTransition verifies the full re-register path: +// spec.vms=[], ownership=VMManaged → CreateVolume called, status.ownership=CSIManaged, +// phase=Succeeded, protection finalizer removed. +func TestReconcile_RegisterTransition(t *testing.T) { + const volID = "vol-register" + cvi := newCVI(volID, nil, csivolumeinfov1alpha1.OwnershipStateVMManaged, + "/vmfs/volumes/ds/disk.vmdk", "disk-uuid-123", 2, + []string{csivolumeinfov1alpha1.VolumeProtectionFinalizer}) + + pv := newTestPV("test-pv") + pvc := newTestPVC("test-pvc", "test-ns") + + s := newScheme(t) + c := newFakeClient(t, s, []client.Object{cvi, pv, pvc}, interceptor.Funcs{}) + + createCalled := false + mgr := &testVolumeManager{ + createVolumeFn: func(_ context.Context, spec *cnstypes.CnsVolumeCreateSpec, + _ interface{}) (*cnsvolumes.CnsVolumeInfo, string, error) { + createCalled = true + return &cnsvolumes.CnsVolumeInfo{}, "", nil + }, + } + + r := &Reconciler{ + client: c, + scheme: s, + configInfo: minimalConfigInfo(), + volumeManager: mgr, + cviSvc: newFakeCviService(c), + } + backOffDuration = make(map[k8stypes.NamespacedName]time.Duration) + + res, err := r.Reconcile(context.Background(), makeRequest(volID)) + require.NoError(t, err) + assert.True(t, res.IsZero()) + assert.True(t, createCalled, "CreateVolume must be called for register transition") + + updated := &csivolumeinfov1alpha1.CsiVolumeInfo{} + require.NoError(t, c.Get(context.Background(), k8stypes.NamespacedName{ + Namespace: csivolumeinfov1alpha1.CVINamespace, + Name: csivolumeinfosvc.GetCsiVolumeInfoCRName(volID), + }, updated)) + + assert.Equal(t, csivolumeinfov1alpha1.OwnershipStateCSIManaged, updated.Status.Ownership) + assert.Equal(t, csivolumeinfov1alpha1.PhaseSucceeded, updated.Status.Phase) + assert.Equal(t, int64(2), updated.Status.ObservedGeneration) + assert.NotContains(t, updated.Finalizers, csivolumeinfov1alpha1.VolumeProtectionFinalizer) +} + +// TestReconcile_RegisterAlreadyExists verifies that a CnsVolumeAlreadyExistsFault +// from CreateVolume is treated as success (idempotent re-register). +func TestReconcile_RegisterAlreadyExists(t *testing.T) { + const volID = "vol-already-exists" + cvi := newCVI(volID, nil, csivolumeinfov1alpha1.OwnershipStateVMManaged, + "/vmfs/volumes/ds/disk.vmdk", "disk-uuid", 1, + []string{csivolumeinfov1alpha1.VolumeProtectionFinalizer}) + + pv := newTestPV("test-pv") + pvc := newTestPVC("test-pvc", "test-ns") + + s := newScheme(t) + c := newFakeClient(t, s, []client.Object{cvi, pv, pvc}, interceptor.Funcs{}) + + mgr := &testVolumeManager{ + createVolumeFn: func(_ context.Context, _ *cnstypes.CnsVolumeCreateSpec, + _ interface{}) (*cnsvolumes.CnsVolumeInfo, string, error) { + // Simulate CnsVolumeAlreadyExistsFault + return nil, "vim.fault.CnsVolumeAlreadyExistsFault", + errors.New("volume already exists") + }, + } + + r := &Reconciler{ + client: c, + scheme: s, + configInfo: minimalConfigInfo(), + volumeManager: mgr, + cviSvc: newFakeCviService(c), + } + backOffDuration = make(map[k8stypes.NamespacedName]time.Duration) + + res, err := r.Reconcile(context.Background(), makeRequest(volID)) + require.NoError(t, err) + assert.True(t, res.IsZero()) + + updated := &csivolumeinfov1alpha1.CsiVolumeInfo{} + require.NoError(t, c.Get(context.Background(), k8stypes.NamespacedName{ + Namespace: csivolumeinfov1alpha1.CVINamespace, + Name: csivolumeinfosvc.GetCsiVolumeInfoCRName(volID), + }, updated)) + assert.Equal(t, csivolumeinfov1alpha1.OwnershipStateCSIManaged, updated.Status.Ownership) + assert.Equal(t, csivolumeinfov1alpha1.PhaseSucceeded, updated.Status.Phase) +} + +// TestReconcile_IdleVMsPresent_VMManaged verifies that a CVI with VMs and +// ownership=VMManaged is idle (the VM already owns the disk, nothing to do). +func TestReconcile_IdleVMsPresent_VMManaged(t *testing.T) { + const volID = "vol-idle-vm" + vms := []csivolumeinfov1alpha1.VirtualMachineRef{{VMName: "vm-a"}, {VMName: "vm-b"}} + cvi := newCVI(volID, vms, csivolumeinfov1alpha1.OwnershipStateVMManaged, "", "", 1, nil) + + s := newScheme(t) + c := newFakeClient(t, s, []client.Object{cvi, newTestPV("test-pv")}, interceptor.Funcs{}) + + callCount := 0 + mgr := &testVolumeManager{ + unregisterVolumeExFn: func(_ context.Context, _ string) (string, string, error) { + callCount++ + return "", "", nil + }, + } + + r := &Reconciler{ + client: c, + scheme: s, + configInfo: minimalConfigInfo(), + volumeManager: mgr, + cviSvc: newFakeCviService(c), + } + backOffDuration = make(map[k8stypes.NamespacedName]time.Duration) + + res, err := r.Reconcile(context.Background(), makeRequest(volID)) + require.NoError(t, err) + assert.True(t, res.IsZero()) + assert.Equal(t, 0, callCount, "UnregisterVolumeEx must NOT be called for idle CVI") +} + +// TestReconcile_IdleNoVMs_CSIManaged verifies that a CVI with no VMs and +// ownership=CSIManaged is idle (already in CSI-managed steady state). +func TestReconcile_IdleNoVMs_CSIManaged(t *testing.T) { + const volID = "vol-idle-csi" + cvi := newCVI(volID, nil, csivolumeinfov1alpha1.OwnershipStateCSIManaged, "", "", 1, nil) + + s := newScheme(t) + c := newFakeClient(t, s, []client.Object{cvi, newTestPV("test-pv")}, interceptor.Funcs{}) + + createCalled := false + mgr := &testVolumeManager{ + createVolumeFn: func(_ context.Context, _ *cnstypes.CnsVolumeCreateSpec, + _ interface{}) (*cnsvolumes.CnsVolumeInfo, string, error) { + createCalled = true + return &cnsvolumes.CnsVolumeInfo{}, "", nil + }, + } + + r := &Reconciler{ + client: c, + scheme: s, + configInfo: minimalConfigInfo(), + volumeManager: mgr, + cviSvc: newFakeCviService(c), + } + backOffDuration = make(map[k8stypes.NamespacedName]time.Duration) + + res, err := r.Reconcile(context.Background(), makeRequest(volID)) + require.NoError(t, err) + assert.True(t, res.IsZero()) + assert.False(t, createCalled, "CreateVolume must NOT be called for idle CVI") +} + +// TestReconcile_UnregisterFault verifies that a CNS fault during UnregisterVolumeEx +// sets status.phase=Failed with an error message and conditions[Ready=False]. +func TestReconcile_UnregisterFault(t *testing.T) { + const volID = "vol-unregister-fault" + vms := []csivolumeinfov1alpha1.VirtualMachineRef{{VMName: "vm-a"}} + cvi := newCVI(volID, vms, csivolumeinfov1alpha1.OwnershipStateCSIManaged, "", "", 5, nil) + + s := newScheme(t) + c := newFakeClient(t, s, []client.Object{cvi, newTestPV("test-pv")}, interceptor.Funcs{}) + + mgr := &testVolumeManager{ + unregisterVolumeExFn: func(_ context.Context, _ string) (string, string, error) { + return "", "", errors.New("CNS unregister fault") + }, + } + + r := &Reconciler{ + client: c, + scheme: s, + configInfo: minimalConfigInfo(), + volumeManager: mgr, + cviSvc: newFakeCviService(c), + } + backOffDuration = make(map[k8stypes.NamespacedName]time.Duration) + + res, err := r.Reconcile(context.Background(), makeRequest(volID)) + require.NoError(t, err) // reconciler swallows error into status, returns nil+requeue + assert.Greater(t, res.RequeueAfter, time.Duration(0), "should requeue after backoff") + + updated := &csivolumeinfov1alpha1.CsiVolumeInfo{} + require.NoError(t, c.Get(context.Background(), k8stypes.NamespacedName{ + Namespace: csivolumeinfov1alpha1.CVINamespace, + Name: csivolumeinfosvc.GetCsiVolumeInfoCRName(volID), + }, updated)) + assert.Equal(t, csivolumeinfov1alpha1.PhaseFailed, updated.Status.Phase) + assert.NotEmpty(t, updated.Status.Error) + assert.Equal(t, int64(5), updated.Status.ObservedGeneration) + require.NotEmpty(t, updated.Status.Conditions) + assert.Equal(t, conditionTypeReady, updated.Status.Conditions[0].Type) + assert.Equal(t, string(metav1.ConditionFalse), string(updated.Status.Conditions[0].Status)) +} + +// TestReconcile_RegisterFault verifies that a CNS fault during CreateVolume +// sets status.phase=Failed and requeueing with backoff. +func TestReconcile_RegisterFault(t *testing.T) { + const volID = "vol-register-fault" + cvi := newCVI(volID, nil, csivolumeinfov1alpha1.OwnershipStateVMManaged, + "/ds/disk.vmdk", "uuid", 2, []string{csivolumeinfov1alpha1.VolumeProtectionFinalizer}) + + pv := newTestPV("test-pv") + pvc := newTestPVC("test-pvc", "test-ns") + + s := newScheme(t) + c := newFakeClient(t, s, []client.Object{cvi, pv, pvc}, interceptor.Funcs{}) + + mgr := &testVolumeManager{ + createVolumeFn: func(_ context.Context, _ *cnstypes.CnsVolumeCreateSpec, + _ interface{}) (*cnsvolumes.CnsVolumeInfo, string, error) { + return nil, "vim.fault.CnsFault", errors.New("create volume fault") + }, + } + + r := &Reconciler{ + client: c, + scheme: s, + configInfo: minimalConfigInfo(), + volumeManager: mgr, + cviSvc: newFakeCviService(c), + } + backOffDuration = make(map[k8stypes.NamespacedName]time.Duration) + + res, err := r.Reconcile(context.Background(), makeRequest(volID)) + require.NoError(t, err) + assert.Greater(t, res.RequeueAfter, time.Duration(0)) + + updated := &csivolumeinfov1alpha1.CsiVolumeInfo{} + require.NoError(t, c.Get(context.Background(), k8stypes.NamespacedName{ + Namespace: csivolumeinfov1alpha1.CVINamespace, + Name: csivolumeinfosvc.GetCsiVolumeInfoCRName(volID), + }, updated)) + assert.Equal(t, csivolumeinfov1alpha1.PhaseFailed, updated.Status.Phase) + assert.Equal(t, int64(2), updated.Status.ObservedGeneration) +} + +// TestReconcile_ObservedGenerationAlwaysSet verifies that observedGeneration is +// written to status on every reconcile path (success and failure). +func TestReconcile_ObservedGenerationAlwaysSet(t *testing.T) { + tests := []struct { + name string + ownership csivolumeinfov1alpha1.OwnershipState + vms []csivolumeinfov1alpha1.VirtualMachineRef + gen int64 + fail bool + }{ + { + name: "unregister success", + vms: []csivolumeinfov1alpha1.VirtualMachineRef{{VMName: "vm-a"}}, + gen: 7, + fail: false, + }, + { + name: "unregister failure", + vms: []csivolumeinfov1alpha1.VirtualMachineRef{{VMName: "vm-a"}}, + gen: 8, + fail: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + volID := "vol-gen-" + tc.name + cvi := newCVI(volID, tc.vms, tc.ownership, "", "", tc.gen, nil) + + s := newScheme(t) + c := newFakeClient(t, s, []client.Object{cvi, newTestPV("test-pv")}, interceptor.Funcs{}) + + mgr := &testVolumeManager{ + unregisterVolumeExFn: func(_ context.Context, _ string) (string, string, error) { + if tc.fail { + return "", "", errors.New("forced error") + } + return "path", "uuid", nil + }, + } + r := &Reconciler{ + client: c, + scheme: s, + configInfo: minimalConfigInfo(), + volumeManager: mgr, + cviSvc: newFakeCviService(c), + } + backOffDuration = make(map[k8stypes.NamespacedName]time.Duration) + + r.Reconcile(context.Background(), makeRequest(volID)) //nolint:errcheck + + updated := &csivolumeinfov1alpha1.CsiVolumeInfo{} + require.NoError(t, c.Get(context.Background(), k8stypes.NamespacedName{ + Namespace: csivolumeinfov1alpha1.CVINamespace, + Name: csivolumeinfosvc.GetCsiVolumeInfoCRName(volID), + }, updated)) + assert.Equal(t, updated.Generation, updated.Status.ObservedGeneration, + "observedGeneration must equal the live spec.generation") + }) + } +} + +// --------------------------------------------------------------------------- +// buildStatusPatch unit test +// --------------------------------------------------------------------------- + +// TestBuildStatusPatch verifies that buildStatusPatch produces a valid JSON +// merge-patch with all required fields. +func TestBuildStatusPatch(t *testing.T) { + patch := buildStatusPatch(42, + csivolumeinfov1alpha1.OwnershipStateVMManaged, + csivolumeinfov1alpha1.PhaseSucceeded, + "", reasonUnregisterSucceeded, true) + require.NotNil(t, patch) + + var m map[string]interface{} + require.NoError(t, json.Unmarshal(patch, &m)) + + status, ok := m["status"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, string(csivolumeinfov1alpha1.OwnershipStateVMManaged), status["ownership"]) + assert.Equal(t, string(csivolumeinfov1alpha1.PhaseSucceeded), status["phase"]) + assert.Equal(t, float64(42), status["observedGeneration"]) + + conds, ok := status["conditions"].([]interface{}) + require.True(t, ok) + require.Len(t, conds, 1) + cond := conds[0].(map[string]interface{}) + assert.Equal(t, conditionTypeReady, cond["type"]) + assert.Equal(t, string(metav1.ConditionTrue), cond["status"]) + assert.Equal(t, reasonUnregisterSucceeded, cond["reason"]) +} + +// --------------------------------------------------------------------------- +// resolveStoragePolicyID unit tests +// --------------------------------------------------------------------------- + +func TestResolveStoragePolicyID_FromPVAttributes(t *testing.T) { + pv := newTestPV("test-pv") + s := newScheme(t) + c := newFakeClient(t, s, nil, interceptor.Funcs{}) + + id := resolveStoragePolicyID(context.Background(), c, pv) + assert.Equal(t, "test-policy", id) +} + +func TestResolveStoragePolicyID_FromStorageClass(t *testing.T) { + pv := &corev1.PersistentVolume{ + ObjectMeta: metav1.ObjectMeta{Name: "test-pv"}, + Spec: corev1.PersistentVolumeSpec{ + StorageClassName: "my-sc", + PersistentVolumeSource: corev1.PersistentVolumeSource{ + CSI: &corev1.CSIPersistentVolumeSource{ + // No storagePolicyID in volumeAttributes + VolumeAttributes: map[string]string{}, + }, + }, + }, + } + sc := &storagev1.StorageClass{ + ObjectMeta: metav1.ObjectMeta{Name: "my-sc"}, + Parameters: map[string]string{"storagePolicyID": "sc-policy-id"}, + } + + s := newScheme(t) + c := newFakeClient(t, s, []client.Object{sc}, interceptor.Funcs{}) + + id := resolveStoragePolicyID(context.Background(), c, pv) + assert.Equal(t, "sc-policy-id", id) +} + +func TestResolveStoragePolicyID_NotFound(t *testing.T) { + pv := &corev1.PersistentVolume{ + ObjectMeta: metav1.ObjectMeta{Name: "test-pv"}, + Spec: corev1.PersistentVolumeSpec{ + StorageClassName: "missing-sc", + PersistentVolumeSource: corev1.PersistentVolumeSource{ + CSI: &corev1.CSIPersistentVolumeSource{ + VolumeAttributes: map[string]string{}, + }, + }, + }, + } + + s := newScheme(t) + c := newFakeClient(t, s, nil, interceptor.Funcs{}) + + id := resolveStoragePolicyID(context.Background(), c, pv) + assert.Empty(t, id) +} + +// --------------------------------------------------------------------------- +// ensurePVOwnerRef unit tests +// --------------------------------------------------------------------------- + +// TestEnsurePVOwnerRef_SetsOwnerRef verifies that when the CVI has no PV +// ownerReference, ensurePVOwnerRef patches it with blockOwnerDeletion=true. +func TestEnsurePVOwnerRef_SetsOwnerRef(t *testing.T) { + const volID = "vol-ownerref-set" + cvi := newCVI(volID, nil, "", "", "", 1, nil) + + pv := &corev1.PersistentVolume{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pv", + UID: k8stypes.UID("pv-uid-abc123"), + }, + } + + s := newScheme(t) + c := newFakeClient(t, s, []client.Object{cvi, pv}, interceptor.Funcs{}) + r := &Reconciler{client: c, scheme: s} + + err := r.ensurePVOwnerRef(context.Background(), cvi) + require.NoError(t, err) + + // Reload CVI and verify the ownerRef was written. + got := &csivolumeinfov1alpha1.CsiVolumeInfo{} + require.NoError(t, c.Get(context.Background(), k8stypes.NamespacedName{ + Namespace: csivolumeinfov1alpha1.CVINamespace, + Name: cvi.Name, + }, got)) + + require.Len(t, got.OwnerReferences, 1) + ref := got.OwnerReferences[0] + assert.Equal(t, "v1", ref.APIVersion) + assert.Equal(t, "PersistentVolume", ref.Kind) + assert.Equal(t, "test-pv", ref.Name) + assert.Equal(t, k8stypes.UID("pv-uid-abc123"), ref.UID) + require.NotNil(t, ref.BlockOwnerDeletion) + assert.True(t, *ref.BlockOwnerDeletion) + assert.Nil(t, ref.Controller, "controller should not be set — PV is owner for GC, not managing controller") +} + +// TestEnsurePVOwnerRef_AlreadySet verifies that ensurePVOwnerRef is a no-op +// when the ownerReference is already present and no Patch call is made. +func TestEnsurePVOwnerRef_AlreadySet(t *testing.T) { + const volID = "vol-ownerref-already" + cvi := newCVI(volID, nil, "", "", "", 1, nil) + cvi.OwnerReferences = []metav1.OwnerReference{ + {APIVersion: "v1", Kind: "PersistentVolume", Name: "test-pv", UID: "existing-uid"}, + } + + s := newScheme(t) + patchCalled := false + c := newFakeClient(t, s, []client.Object{cvi}, interceptor.Funcs{ + Patch: func(ctx context.Context, cl client.WithWatch, obj client.Object, + patch client.Patch, opts ...client.PatchOption) error { + patchCalled = true + return nil + }, + }) + r := &Reconciler{client: c, scheme: s} + + err := r.ensurePVOwnerRef(context.Background(), cvi) + require.NoError(t, err) + assert.False(t, patchCalled, "Patch must not be called when ownerRef is already set") +} + +// TestEnsurePVOwnerRef_PVNotFound verifies that a missing PV returns an error +// so controller-runtime requeues the reconcile. +func TestEnsurePVOwnerRef_PVNotFound(t *testing.T) { + const volID = "vol-ownerref-nopv" + cvi := newCVI(volID, nil, "", "", "", 1, nil) + + s := newScheme(t) + c := newFakeClient(t, s, []client.Object{cvi}, interceptor.Funcs{}) + r := &Reconciler{client: c, scheme: s} + + err := r.ensurePVOwnerRef(context.Background(), cvi) + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +// TestEnsurePVOwnerRef_EmptyPVName verifies that a CVI with no spec.pvName +// is silently skipped. +func TestEnsurePVOwnerRef_EmptyPVName(t *testing.T) { + const volID = "vol-ownerref-nopvname" + cvi := newCVI(volID, nil, "", "", "", 1, nil) + cvi.Spec.PVName = "" + + s := newScheme(t) + patchCalled := false + c := newFakeClient(t, s, []client.Object{cvi}, interceptor.Funcs{ + Patch: func(ctx context.Context, cl client.WithWatch, obj client.Object, + patch client.Patch, opts ...client.PatchOption) error { + patchCalled = true + return nil + }, + }) + r := &Reconciler{client: c, scheme: s} + + err := r.ensurePVOwnerRef(context.Background(), cvi) + require.NoError(t, err) + assert.False(t, patchCalled, "Patch must not be called when pvName is empty") +} + +// TestReconcile_SetsOwnerRefOnInitialState verifies that a full Reconcile on a +// freshly-created CVI (no VMs, no ownership) sets the PV ownerRef and writes +// the initial CSIManaged status. +func TestReconcile_SetsOwnerRefOnInitialState(t *testing.T) { + const volID = "vol-ownerref-initial" + cvi := newCVI(volID, nil, "", "", "", 1, nil) + + pv := &corev1.PersistentVolume{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pv", + UID: k8stypes.UID("pv-uid-initial"), + }, + } + + s := newScheme(t) + c := newFakeClient(t, s, []client.Object{cvi, pv}, interceptor.Funcs{}) + r := &Reconciler{ + client: c, + scheme: s, + configInfo: minimalConfigInfo(), + volumeManager: &testVolumeManager{}, + cviSvc: newFakeCviService(c), + } + backOffDuration = make(map[k8stypes.NamespacedName]time.Duration) + + res, err := r.Reconcile(context.Background(), makeRequest(volID)) + require.NoError(t, err) + assert.True(t, res.IsZero()) + + got := &csivolumeinfov1alpha1.CsiVolumeInfo{} + require.NoError(t, c.Get(context.Background(), k8stypes.NamespacedName{ + Namespace: csivolumeinfov1alpha1.CVINamespace, + Name: csivolumeinfosvc.GetCsiVolumeInfoCRName(volID), + }, got)) + + // ownerRef must be set. + require.Len(t, got.OwnerReferences, 1) + assert.Equal(t, k8stypes.UID("pv-uid-initial"), got.OwnerReferences[0].UID) +} + +// --------------------------------------------------------------------------- +// Backoff helper unit tests +// --------------------------------------------------------------------------- + +func TestBackoffHelpers(t *testing.T) { + backOffDuration = make(map[k8stypes.NamespacedName]time.Duration) + ctx := context.Background() + nn := k8stypes.NamespacedName{Name: "test"} + + // Initial backoff should be 1s. + d := getBackoffDuration(ctx, nn) + assert.Equal(t, time.Second, d) + + // Double it. + doubleBackoffDuration(ctx, nn) + d = getBackoffDuration(ctx, nn) + assert.Equal(t, 2*time.Second, d) + + // Delete it. + deleteBackoffEntry(ctx, nn) + assert.NotContains(t, backOffDuration, nn) +} diff --git a/pkg/syncer/cnsoperator/manager/init.go b/pkg/syncer/cnsoperator/manager/init.go index 62c47a1aad..48f7172a80 100644 --- a/pkg/syncer/cnsoperator/manager/init.go +++ b/pkg/syncer/cnsoperator/manager/init.go @@ -38,6 +38,7 @@ import ( cnsoperatorv1alpha1 "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator" cnsvolumemetadatav1alpha1 "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/cnsvolumemetadata/v1alpha1" cnsoperatorconfig "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/config" + csivolumeinfocfg "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/csivolumeinfo/config" wcpcapapis "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/wcpcapabilities" volumes "sigs.k8s.io/vsphere-csi-driver/v3/pkg/common/cns-lib/volume" cnsvsphere "sigs.k8s.io/vsphere-csi-driver/v3/pkg/common/cns-lib/vsphere" @@ -350,6 +351,16 @@ func InitCnsOperator(ctx context.Context, clusterFlavor cnstypes.CnsClusterFlavo } } } + + if cnsOperator.coCommonInterface.IsFSSEnabled(ctx, common.VMOwnedVolumes) { + err = k8s.CreateCustomResourceDefinitionFromManifest(ctx, + csivolumeinfocfg.EmbedCsiVolumeInfoCRFile, + csivolumeinfocfg.EmbedCsiVolumeInfoCRFileName) + if err != nil { + log.Errorf("failed to create CsiVolumeInfo CRD. Err: %+v", err) + return err + } + } } else if clusterFlavor == cnstypes.CnsClusterFlavorVanilla { // Create CSINodeTopology CRD. err = k8s.CreateCustomResourceDefinitionFromManifest(ctx, csinodetopologyconfig.EmbedCSINodeTopologyFile, diff --git a/pkg/syncer/cvi_pv_bind.go b/pkg/syncer/cvi_pv_bind.go new file mode 100644 index 0000000000..9bb73cf8bd --- /dev/null +++ b/pkg/syncer/cvi_pv_bind.go @@ -0,0 +1,130 @@ +/* +Copyright 2026 The Kubernetes Authors. + +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 syncer + +import ( + "context" + "encoding/json" + + v1 "k8s.io/api/core/v1" + + csivolumeinfosvc "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/csivolumeinfo" + "sigs.k8s.io/vsphere-csi-driver/v3/pkg/csi/service/logger" + csitypes "sigs.k8s.io/vsphere-csi-driver/v3/pkg/csi/types" +) + +// ReconcileCsiVolumeInfoOnPVBind is called when a PV transitions from +// Available to Bound. When the VMOwnedVolumes capability is active, this +// function ensures the CsiVolumeInfo CR for the volume has up-to-date +// spec.pvName, spec.pvcName, and spec.pvcNamespace fields. +// +// The function is idempotent: if no CsiVolumeInfo CR exists for the volume +// (e.g., pre-existing PVs created before the feature was enabled), it logs +// a debug message and returns nil. Errors from the patch are returned so +// the caller can decide whether to retry. +func ReconcileCsiVolumeInfoOnPVBind( + ctx context.Context, + oldPV, newPV *v1.PersistentVolume, + svc csivolumeinfosvc.CsiVolumeInfoService, +) error { + log := logger.GetLogger(ctx) + + // Only act on the Available → Bound transition. + if oldPV.Status.Phase == newPV.Status.Phase { + return nil + } + if newPV.Status.Phase != v1.VolumeBound { + return nil + } + + // Only handle vSphere CSI volumes. + if newPV.Spec.CSI == nil || newPV.Spec.CSI.Driver != csitypes.Name { + return nil + } + + volumeID := newPV.Spec.CSI.VolumeHandle + if volumeID == "" { + log.Warnf("ReconcileCsiVolumeInfoOnPVBind: PV %q has empty volumeHandle; skipping", newPV.Name) + return nil + } + + // Retrieve the CsiVolumeInfo CR. A nil result means the CR does not exist. + cvi, err := svc.GetCsiVolumeInfo(ctx, volumeID) + if err != nil { + log.Errorf("ReconcileCsiVolumeInfoOnPVBind: failed to get CsiVolumeInfo for volumeID=%q pv=%q: %v", + volumeID, newPV.Name, err) + return err + } + if cvi == nil { + log.Debugf("ReconcileCsiVolumeInfoOnPVBind: no CsiVolumeInfo found for volumeID=%q pv=%q; skipping", + volumeID, newPV.Name) + return nil + } + + // Derive desired PVC name/namespace from the PV's ClaimRef. + var desiredPVCName, desiredPVCNamespace string + if newPV.Spec.ClaimRef != nil { + desiredPVCName = newPV.Spec.ClaimRef.Name + desiredPVCNamespace = newPV.Spec.ClaimRef.Namespace + } + + // Skip the patch if nothing has changed. + if cvi.Spec.PVName == newPV.Name && + (desiredPVCName == "" || cvi.Spec.PVCName == desiredPVCName) && + (desiredPVCNamespace == "" || cvi.Spec.PVCNamespace == desiredPVCNamespace) { + log.Debugf("ReconcileCsiVolumeInfoOnPVBind: CsiVolumeInfo for volumeID=%q already up-to-date; skipping", + volumeID) + return nil + } + + // Build a merge-patch with only the fields that need updating. + type specPatch struct { + PVName string `json:"pvName,omitempty"` + PVCName string `json:"pvcName,omitempty"` + PVCNamespace string `json:"pvcNamespace,omitempty"` + } + type patch struct { + Spec specPatch `json:"spec"` + } + p := patch{ + Spec: specPatch{ + PVName: newPV.Name, + PVCName: desiredPVCName, + PVCNamespace: desiredPVCNamespace, + }, + } + patchBytes, err := json.Marshal(p) + if err != nil { + log.Errorf("ReconcileCsiVolumeInfoOnPVBind: failed to marshal patch for volumeID=%q pv=%q: %v", + volumeID, newPV.Name, err) + return err + } + + log.Infof("ReconcileCsiVolumeInfoOnPVBind: patching CsiVolumeInfo for volumeID=%q pv=%q pvcName=%q pvcNamespace=%q", + volumeID, newPV.Name, desiredPVCName, desiredPVCNamespace) + // The returned generation is not needed here; this reconcile only refreshes + // PVC/PV identity fields and does not write status. + if _, err := svc.PatchCsiVolumeInfo(ctx, volumeID, patchBytes); err != nil { + log.Errorf("ReconcileCsiVolumeInfoOnPVBind: failed to patch CsiVolumeInfo for volumeID=%q pv=%q: %v", + volumeID, newPV.Name, err) + return err + } + + log.Infof("ReconcileCsiVolumeInfoOnPVBind: successfully patched CsiVolumeInfo for volumeID=%q pv=%q", + volumeID, newPV.Name) + return nil +} diff --git a/pkg/syncer/cvi_pv_bind_test.go b/pkg/syncer/cvi_pv_bind_test.go new file mode 100644 index 0000000000..9882873ab0 --- /dev/null +++ b/pkg/syncer/cvi_pv_bind_test.go @@ -0,0 +1,233 @@ +/* +Copyright 2026 The Kubernetes Authors. + +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 syncer + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + v1 "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" + + csivolumeinfosvc "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/csivolumeinfo" + csivolumeinfov1alpha1 "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/csivolumeinfo/v1alpha1" + csitypes "sigs.k8s.io/vsphere-csi-driver/v3/pkg/csi/types" +) + +// newCVIScheme returns a runtime.Scheme with CsiVolumeInfo types registered. +func newCVIScheme(t *testing.T) *runtime.Scheme { + t.Helper() + s := runtime.NewScheme() + require.NoError(t, csivolumeinfov1alpha1.AddToScheme(s)) + return s +} + +// makeCsiPV returns a minimal vSphere CSI PV with the given phase and volumeHandle. +func makeCsiPV(name, volumeHandle string, phase v1.PersistentVolumePhase) *v1.PersistentVolume { + return &v1.PersistentVolume{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Spec: v1.PersistentVolumeSpec{ + PersistentVolumeSource: v1.PersistentVolumeSource{ + CSI: &v1.CSIPersistentVolumeSource{ + Driver: csitypes.Name, + VolumeHandle: volumeHandle, + }, + }, + }, + Status: v1.PersistentVolumeStatus{Phase: phase}, + } +} + +// makeCsiPVWithClaimRef adds a ClaimRef to a vSphere CSI PV. +func makeCsiPVWithClaimRef(name, volumeHandle, pvcName, pvcNamespace string, + phase v1.PersistentVolumePhase) *v1.PersistentVolume { + pv := makeCsiPV(name, volumeHandle, phase) + pv.Spec.ClaimRef = &v1.ObjectReference{ + Name: pvcName, + Namespace: pvcNamespace, + } + return pv +} + +// makeCVIForPVBind returns a minimal CsiVolumeInfo for the given volumeID. +func makeCVIForPVBind(volumeID, pvName, pvcName, pvcNamespace string) *csivolumeinfov1alpha1.CsiVolumeInfo { + return &csivolumeinfov1alpha1.CsiVolumeInfo{ + ObjectMeta: metav1.ObjectMeta{ + Name: csivolumeinfosvc.GetCsiVolumeInfoCRName(volumeID), + Namespace: csivolumeinfov1alpha1.CVINamespace, + }, + Spec: csivolumeinfov1alpha1.CsiVolumeInfoSpec{ + VolumeID: volumeID, + PVName: pvName, + PVCName: pvcName, + PVCNamespace: pvcNamespace, + }, + } +} + +// TestReconcileCviPVBind_NoTransition verifies that the function is a no-op +// when the PV phase does not change. +func TestReconcileCviPVBind_NoTransition(t *testing.T) { + ctx := context.Background() + scheme := newCVIScheme(t) + fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() + svc := csivolumeinfosvc.NewCsiVolumeInfoService(fakeClient) + + pv := makeCsiPV("pv-1", "vol-aaa", v1.VolumeBound) + err := ReconcileCsiVolumeInfoOnPVBind(ctx, pv, pv, svc) + require.NoError(t, err) +} + +// TestReconcileCviPVBind_NotBound verifies that a transition to Released is ignored. +func TestReconcileCviPVBind_NotBound(t *testing.T) { + ctx := context.Background() + scheme := newCVIScheme(t) + fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() + svc := csivolumeinfosvc.NewCsiVolumeInfoService(fakeClient) + + oldPV := makeCsiPV("pv-1", "vol-aaa", v1.VolumeBound) + newPV := makeCsiPV("pv-1", "vol-aaa", v1.VolumeReleased) + err := ReconcileCsiVolumeInfoOnPVBind(ctx, oldPV, newPV, svc) + require.NoError(t, err) +} + +// TestReconcileCviPVBind_NotVspherePV verifies that non-vSphere PVs are skipped. +func TestReconcileCviPVBind_NotVspherePV(t *testing.T) { + ctx := context.Background() + scheme := newCVIScheme(t) + fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() + svc := csivolumeinfosvc.NewCsiVolumeInfoService(fakeClient) + + oldPV := &v1.PersistentVolume{ + ObjectMeta: metav1.ObjectMeta{Name: "pv-nfs"}, + Spec: v1.PersistentVolumeSpec{ + PersistentVolumeSource: v1.PersistentVolumeSource{ + NFS: &v1.NFSVolumeSource{Server: "nfs-server", Path: "/exports"}, + }, + }, + Status: v1.PersistentVolumeStatus{Phase: v1.VolumeAvailable}, + } + newPV := oldPV.DeepCopy() + newPV.Status.Phase = v1.VolumeBound + err := ReconcileCsiVolumeInfoOnPVBind(ctx, oldPV, newPV, svc) + require.NoError(t, err) +} + +// TestReconcileCviPVBind_NoCVI verifies that a missing CsiVolumeInfo is a +// no-op (the function returns nil). +func TestReconcileCviPVBind_NoCVI(t *testing.T) { + ctx := context.Background() + scheme := newCVIScheme(t) + fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() + svc := csivolumeinfosvc.NewCsiVolumeInfoService(fakeClient) + + oldPV := makeCsiPV("pv-1", "vol-no-cvi", v1.VolumeAvailable) + newPV := makeCsiPVWithClaimRef("pv-1", "vol-no-cvi", "my-pvc", "default", v1.VolumeBound) + err := ReconcileCsiVolumeInfoOnPVBind(ctx, oldPV, newPV, svc) + require.NoError(t, err) +} + +// TestReconcileCviPVBind_PatchesPVName verifies that when a CsiVolumeInfo +// exists with a stale pvName, the patch updates it correctly. +func TestReconcileCviPVBind_PatchesPVName(t *testing.T) { + ctx := context.Background() + volumeID := "vol-123" + scheme := newCVIScheme(t) + + cvi := makeCVIForPVBind(volumeID, "old-pv-name", "my-pvc", "default") + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(cvi). + WithStatusSubresource(cvi). + Build() + svc := csivolumeinfosvc.NewCsiVolumeInfoService(fakeClient) + + oldPV := makeCsiPV("new-pv-name", volumeID, v1.VolumeAvailable) + newPV := makeCsiPVWithClaimRef("new-pv-name", volumeID, "my-pvc", "default", v1.VolumeBound) + + err := ReconcileCsiVolumeInfoOnPVBind(ctx, oldPV, newPV, svc) + require.NoError(t, err) + + // Verify the patch was applied. + updated, fetchErr := svc.GetCsiVolumeInfo(ctx, volumeID) + require.NoError(t, fetchErr) + require.NotNil(t, updated) + assert.Equal(t, "new-pv-name", updated.Spec.PVName) + assert.Equal(t, "my-pvc", updated.Spec.PVCName) + assert.Equal(t, "default", updated.Spec.PVCNamespace) +} + +// TestReconcileCviPVBind_AlreadyUpToDate verifies that no patch is issued +// when the CsiVolumeInfo spec already matches the PV. +func TestReconcileCviPVBind_AlreadyUpToDate(t *testing.T) { + ctx := context.Background() + volumeID := "vol-456" + scheme := newCVIScheme(t) + + cvi := makeCVIForPVBind(volumeID, "pv-456", "my-pvc", "ns1") + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(cvi). + WithStatusSubresource(cvi). + Build() + svc := csivolumeinfosvc.NewCsiVolumeInfoService(fakeClient) + + oldPV := makeCsiPV("pv-456", volumeID, v1.VolumeAvailable) + newPV := makeCsiPVWithClaimRef("pv-456", volumeID, "my-pvc", "ns1", v1.VolumeBound) + + err := ReconcileCsiVolumeInfoOnPVBind(ctx, oldPV, newPV, svc) + require.NoError(t, err) +} + +// TestReconcileCviPVBind_EmptyVolumeHandle verifies that a PV with an empty +// volumeHandle is safely skipped. +func TestReconcileCviPVBind_EmptyVolumeHandle(t *testing.T) { + ctx := context.Background() + scheme := newCVIScheme(t) + fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() + svc := csivolumeinfosvc.NewCsiVolumeInfoService(fakeClient) + + oldPV := makeCsiPV("pv-empty", "", v1.VolumeAvailable) + newPV := makeCsiPV("pv-empty", "", v1.VolumeBound) + + err := ReconcileCsiVolumeInfoOnPVBind(ctx, oldPV, newPV, svc) + require.NoError(t, err) +} + +// TestPatchStructJSON verifies that the internal patch struct marshals +// correctly (regression guard for field names used in merge-patch). +func TestPatchStructJSON(t *testing.T) { + type specPatch struct { + PVName string `json:"pvName,omitempty"` + PVCName string `json:"pvcName,omitempty"` + PVCNamespace string `json:"pvcNamespace,omitempty"` + } + type patch struct { + Spec specPatch `json:"spec"` + } + p := patch{Spec: specPatch{PVName: "pv-1", PVCName: "pvc-1", PVCNamespace: "ns-1"}} + b, err := json.Marshal(p) + require.NoError(t, err) + assert.Contains(t, string(b), `"pvName":"pv-1"`) + assert.Contains(t, string(b), `"pvcName":"pvc-1"`) + assert.Contains(t, string(b), `"pvcNamespace":"ns-1"`) +} diff --git a/pkg/syncer/fullsync_test.go b/pkg/syncer/fullsync_test.go index 9366f7951e..d50b74a04b 100644 --- a/pkg/syncer/fullsync_test.go +++ b/pkg/syncer/fullsync_test.go @@ -977,6 +977,26 @@ func (m *mockVolumeManagerForFullSync) UnregisterVolume( return "", nil } +func (m *mockVolumeManagerForFullSync) UnregisterVolumeEx( + ctx context.Context, volumeID string, +) (string, string, error) { + return "", "", nil +} + +func (m *mockVolumeManagerForFullSync) QueryPendingUnregisters( + ctx context.Context, +) ([]volumes.PendingUnregisterRecord, error) { + return nil, nil +} + +func (m *mockVolumeManagerForFullSync) AckUnregister(ctx context.Context, volumeID string) error { + return nil +} + +func (m *mockVolumeManagerForFullSync) GetDiskFolderURL(ctx context.Context, datastorePath string) (string, error) { + return "", nil +} + // TestCNSQueryBatching tests CNS query batching logic func TestCNSQueryBatching_VaryingBatchSizes(t *testing.T) { if testing.Short() { diff --git a/pkg/syncer/metadatasyncer.go b/pkg/syncer/metadatasyncer.go index 13266ae381..11ebe92338 100644 --- a/pkg/syncer/metadatasyncer.go +++ b/pkg/syncer/metadatasyncer.go @@ -57,6 +57,7 @@ import ( "github.com/go-logr/zapr" cr_log "sigs.k8s.io/controller-runtime/pkg/log" cnsoperatorv1alpha1 "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator" + csivolumeinfosvc "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/csivolumeinfo" storagepolicyv1alpha2 "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/storagepolicy/v1alpha2" storagepolicyv1alpha3 "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/storagepolicy/v1alpha3" sqperiodicsyncv1alpha1 "sigs.k8s.io/vsphere-csi-driver/v3/pkg/apis/cnsoperator/storagequotaperiodicsync/v1alpha1" @@ -519,6 +520,19 @@ func InitMetadataSyncer(ctx context.Context, clusterFlavor cnstypes.CnsClusterFl } metadataSyncer.volumeManager = volumeManager + // On startup, complete any two-phase FCD unregister operations that were + // interrupted by a crash. This is a no-op when VMOwnedVolumes is disabled. + if commonco.ContainerOrchestratorUtility.IsFSSEnabled(ctx, common.VMOwnedVolumes) { + cviSvc, cviSvcErr := csivolumeinfosvc.InitCsiVolumeInfoService(ctx) + if cviSvcErr != nil { + log.Warnf("RecoverPendingUnregisters: failed to init CVI service: %v; skipping startup recovery", + cviSvcErr) + } else if recoveryErr := csivolumeinfosvc.RecoverPendingUnregisters(ctx, + metadataSyncer.volumeManager, cviSvc); recoveryErr != nil { + log.Warnf("RecoverPendingUnregisters: startup recovery encountered errors: %v", recoveryErr) + } + } + // Initialize a CnsOperator client for supervisor-mode operations // (e.g. CNSVolumeInfo and StoragePolicyUsage patches during // VMPVCStoragePolicyMutability storage-policy migrations).