diff --git a/cmd/main.go b/cmd/main.go index d39bc3f49..c35bbb1ed 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -42,6 +42,7 @@ import ( "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/handlers/options" "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/webhook/cluster" "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/webhook/preflight" + preflightnutanix "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/webhook/preflight/nutanix" ) func main() { @@ -224,6 +225,7 @@ func main() { Handler: preflight.New(mgr.GetClient(), admission.NewDecoder(mgr.GetScheme()), []preflight.Checker{ // Add your preflight checkers here. + preflightnutanix.Checker, }..., ), }) diff --git a/go.mod b/go.mod index d9580e851..951d0f879 100644 --- a/go.mod +++ b/go.mod @@ -24,6 +24,7 @@ require ( github.com/nutanix/ntnx-api-golang-clients/clustermgmt-go-client/v4 v4.0.1-beta.2 github.com/nutanix/ntnx-api-golang-clients/networking-go-client/v4 v4.0.2-beta.1 github.com/nutanix/ntnx-api-golang-clients/prism-go-client/v4 v4.0.1-beta.1 + github.com/nutanix/ntnx-api-golang-clients/vmm-go-client/v4 v4.0.1-beta.1 github.com/onsi/ginkgo/v2 v2.23.4 github.com/onsi/gomega v1.37.0 github.com/pkg/errors v0.9.1 @@ -56,6 +57,8 @@ require ( github.com/Masterminds/semver/v3 v3.3.0 // indirect github.com/Masterminds/sprig/v3 v3.3.0 // indirect github.com/Microsoft/go-winio v0.6.1 // indirect + github.com/PaesslerAG/gval v1.0.0 // indirect + github.com/PaesslerAG/jsonpath v0.1.1 // indirect github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 // indirect github.com/adrg/xdg v0.5.3 // indirect github.com/antlr4-go/antlr/v4 v4.13.0 // indirect @@ -78,9 +81,16 @@ require ( github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-logr/zapr v1.3.0 // indirect + github.com/go-openapi/analysis v0.23.0 // indirect + github.com/go-openapi/errors v0.22.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect + github.com/go-openapi/loads v0.22.0 // indirect + github.com/go-openapi/spec v0.21.0 // indirect + github.com/go-openapi/strfmt v0.23.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-openapi/validate v0.24.0 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/go-viper/mapstructure/v2 v2.2.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect @@ -106,14 +116,15 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.14 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/nutanix/ntnx-api-golang-clients/storage-go-client/v4 v4.0.2-alpha.3 // indirect - github.com/nutanix/ntnx-api-golang-clients/vmm-go-client/v4 v4.0.1-beta.1 // indirect github.com/nutanix/ntnx-api-golang-clients/volumes-go-client/v4 v4.0.1-beta.1 // indirect + github.com/oklog/ulid v1.3.1 // indirect github.com/olekukonko/tablewriter v0.0.5 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.0-rc5 // indirect @@ -137,6 +148,7 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect github.com/valyala/fastjson v1.6.4 // indirect github.com/x448/float16 v0.8.4 // indirect + go.mongodb.org/mongo-driver v1.14.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect go.opentelemetry.io/otel v1.29.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 // indirect @@ -147,6 +159,7 @@ require ( go.opentelemetry.io/proto/otlp v1.3.1 // indirect go.uber.org/automaxprocs v1.6.0 // indirect go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect golang.org/x/crypto v0.36.0 // indirect golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect golang.org/x/mod v0.24.0 // indirect diff --git a/go.sum b/go.sum index 1446a360b..cc35a739d 100644 --- a/go.sum +++ b/go.sum @@ -18,12 +18,19 @@ github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= +github.com/PaesslerAG/gval v1.0.0 h1:GEKnRwkWDdf9dOmKcNrar9EA1bz1z9DqPIO1+iLzhd8= +github.com/PaesslerAG/gval v1.0.0/go.mod h1:y/nm5yEyTeX6av0OfKJNp9rBNj2XrGhAf5+v24IBN1I= +github.com/PaesslerAG/jsonpath v0.1.0/go.mod h1:4BzmtoM/PI8fPO4aQGIusjGxGir2BzcV0grWtFzq1Y8= +github.com/PaesslerAG/jsonpath v0.1.1 h1:c1/AToHQMVsduPAa4Vh6xp2U0evy4t8SWp8imEsylIk= +github.com/PaesslerAG/jsonpath v0.1.1/go.mod h1:lVboNxFGal/VwW6d9JzIy56bUsYAP6tH/x80vjnCseY= github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 h1:wPbRQzjjwFc0ih8puEVAOFGELsn1zoIIYdxvML7mDxA= github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g= github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= +github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA= +github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE= @@ -52,6 +59,8 @@ github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf h1:iW4rZ826su+pq github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creasty/defaults v1.6.0 h1:ltuE9cfphUtlrBeomuu8PEyISTXnxqkBIoQfXgv7BSc= +github.com/creasty/defaults v1.6.0/go.mod h1:iGzKe6pbEHnpMPtfDXZEr0NVxWnPTjb1bbDy08fPzYM= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -80,6 +89,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fullstorydev/grpcurl v1.8.7 h1:xJWosq3BQovQ4QrdPO72OrPiWuGgEsxY8ldYsJbPrqI= +github.com/fullstorydev/grpcurl v1.8.7/go.mod h1:pVtM4qe3CMoLaIzYS8uvTuDj2jVYmXqMUkZeijnXp/E= github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -89,14 +100,34 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-openapi/analysis v0.23.0 h1:aGday7OWupfMs+LbmLZG4k0MYXIANxcuBTYUC03zFCU= +github.com/go-openapi/analysis v0.23.0/go.mod h1:9mz9ZWaSlV8TvjQHLl2mUW2PbZtemkE8yA5v22ohupo= +github.com/go-openapi/errors v0.22.0 h1:c4xY/OLxUBSTiepAg3j/MHuAv5mJhnf53LLMWFB+u/w= +github.com/go-openapi/errors v0.22.0/go.mod h1:J3DmZScxCDufmIMsdOuDHxJbdOGC0xtUynjIx092vXE= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= +github.com/go-openapi/loads v0.22.0 h1:ECPGd4jX1U6NApCGG1We+uEozOAvXvJSF4nnwHZ8Aco= +github.com/go-openapi/loads v0.22.0/go.mod h1:yLsaTCS92mnSAZX5WWoxszLj0u+Ojl+Zs5Stn1oF+rs= +github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY= +github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= +github.com/go-openapi/strfmt v0.23.0 h1:nlUS6BCqcnAk0pyhi9Y+kdDVZdZMHfEKQiS4HaMgO/c= +github.com/go-openapi/strfmt v0.23.0/go.mod h1:NrtIpfKtWIygRkKVsxh7XQMDQW5HKQl6S5ik2elW+K4= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-openapi/validate v0.24.0 h1:LdfDKwNbpB6Vn40xhTdNZAnfLECL81w+VX3BumrGD58= +github.com/go-openapi/validate v0.24.0/go.mod h1:iyeX1sEufmv3nPbBdX3ieNviWnOZaJ1+zquzJEf2BAQ= +github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU= +github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs= +github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/jYrnRPArHwAcmLoJZxyho= +github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA= +github.com/go-playground/validator/v10 v10.10.1 h1:uA0+amWMiglNZKZ9FJRKUAe9U3RX91eVn1JYXMWt7ig= +github.com/go-playground/validator/v10 v10.10.1/go.mod h1:i+3WkQ1FvaUjjxh1kSvIA4dMGDBiPU55YFDl0WbKdWU= 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/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= +github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4= @@ -146,6 +177,8 @@ github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jhump/protoreflect v1.14.0 h1:MBbQK392K3u8NTLbKOCIi3XdI+y+c6yt5oMq0X3xviw= +github.com/jhump/protoreflect v1.14.0/go.mod h1:JytZfP5d0r8pVNLZvai7U/MCuTWITgrI4tTg7puQFKI= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= @@ -154,12 +187,18 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/k0kubun/pp/v3 v3.1.0 h1:ifxtqJkRZhw3h554/z/8zm6AAbyO4LLKDlA5eV+9O8Q= +github.com/k0kubun/pp/v3 v3.1.0/go.mod h1:vIrP5CF0n78pKHm2Ku6GVerpZBJvscg48WepUYEk2gw= +github.com/keploy/go-sdk v0.9.0 h1:kpSNcCTDdELsa1gWyhoD9oV57SgSMbG/wq6Cjp4y7cY= +github.com/keploy/go-sdk v0.9.0/go.mod h1:vNKXoFd2MaK+Gly/K6XeP1Hs9dP834C74szH+vtBPwg= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w= +github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= @@ -172,6 +211,8 @@ github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWV github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= @@ -201,6 +242,8 @@ github.com/nutanix/ntnx-api-golang-clients/vmm-go-client/v4 v4.0.1-beta.1 h1:XuT github.com/nutanix/ntnx-api-golang-clients/vmm-go-client/v4 v4.0.1-beta.1/go.mod h1:CaWm4GFpAjQQDc6YXl/dUDrHpuW54h8j6Cj7EslE4Qk= github.com/nutanix/ntnx-api-golang-clients/volumes-go-client/v4 v4.0.1-beta.1 h1:VJSaQDnnYeNEk1mkQqEbt573OdM62+5s/B0e9kszdas= github.com/nutanix/ntnx-api-golang-clients/volumes-go-client/v4 v4.0.1-beta.1/go.mod h1:Z+RKLwsHYxAcFbZPy2ft3QAK9kBPt9bQdqXSp7eYWkY= +github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus= @@ -283,6 +326,10 @@ go.etcd.io/etcd/client/pkg/v3 v3.5.20 h1:sZIAtra+xCo56gdf6BR62to/hiie5Bwl7hQIqMz go.etcd.io/etcd/client/pkg/v3 v3.5.20/go.mod h1:qaOi1k4ZA9lVLejXNvyPABrVEe7VymMF2433yyRQ7O0= go.etcd.io/etcd/client/v3 v3.5.20 h1:jMT2MwQEhyvhQg49Cec+1ZHJzfUf6ZgcmV0GjPv0tIQ= go.etcd.io/etcd/client/v3 v3.5.20/go.mod h1:J5lbzYRMUR20YolS5UjlqqMcu3/wdEvG5VNBhzyo3m0= +go.keploy.io/server v0.8.6 h1:czE9jaliyAkMMJcYnMPNuu6tun7UgwFbokxEG95vLN4= +go.keploy.io/server v0.8.6/go.mod h1:t7BPuZQSiC3PNHZ9dbn3e3VB61HNWwiqVmaRujfDFUg= +go.mongodb.org/mongo-driver v1.14.0 h1:P98w8egYRjYe3XDjxhYJagTokP/H6HzlsnojRgZRd80= +go.mongodb.org/mongo-driver v1.14.0/go.mod h1:Vzb0Mk/pa7e6cWw85R4F/endUC3u0U9jGcNU603k65c= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 h1:r6I7RJCN86bpD/FQwedZ0vSixDpwuWREjW9oRMsmqDc= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= @@ -369,6 +416,8 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 h1:ToEetK57OidYuqD4Q5w+vfEnPvPpuTwedCNVohYJfNk= +google.golang.org/genproto v0.0.0-20241118233622-e639e219e697/go.mod h1:JJrvXBWRZaFMxBufik1a4RpFw4HhgVtBBWQeQgUj2cc= google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 h1:CkkIfIt50+lT6NHAVoRYEyAvQGFM7xEwXUUywFvEb3Q= google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08= google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 h1:TqExAhdPaB60Ux47Cn0oLV07rGnxZzIsaRhQaqS666A= diff --git a/pkg/webhook/preflight/nutanix/checker.go b/pkg/webhook/preflight/nutanix/checker.go new file mode 100644 index 000000000..dc61ec445 --- /dev/null +++ b/pkg/webhook/preflight/nutanix/checker.go @@ -0,0 +1,76 @@ +// Copyright 2025 Nutanix. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package nutanix + +import ( + "context" + + "github.com/go-logr/logr" + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" + ctrl "sigs.k8s.io/controller-runtime" + ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" + + prismgoclient "github.com/nutanix-cloud-native/prism-go-client" + + carenv1 "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/api/v1alpha1" + "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/webhook/preflight" +) + +var Checker = &nutanixChecker{ + configurationCheckFactory: newConfigurationCheck, + credentialsCheckFactory: newCredentialsCheck, + vmImageChecksFactory: newVMImageChecks, +} + +type nutanixChecker struct { + configurationCheckFactory func( + cd *checkDependencies, + ) preflight.Check + + credentialsCheckFactory func( + ctx context.Context, + nclientFactory func(prismgoclient.Credentials) (client, error), + cd *checkDependencies, + ) preflight.Check + + vmImageChecksFactory func( + cd *checkDependencies, + ) []preflight.Check +} + +type checkDependencies struct { + kclient ctrlclient.Client + cluster *clusterv1.Cluster + + nutanixClusterConfigSpec *carenv1.NutanixClusterConfigSpec + nutanixWorkerNodeConfigSpecByMachineDeploymentName map[string]*carenv1.NutanixWorkerNodeConfigSpec + + nclient client + log logr.Logger +} + +func (n *nutanixChecker) Init( + ctx context.Context, + kclient ctrlclient.Client, + cluster *clusterv1.Cluster, +) []preflight.Check { + cd := &checkDependencies{ + kclient: kclient, + cluster: cluster, + log: ctrl.LoggerFrom(ctx).WithName("preflight/nutanix"), + } + + checks := []preflight.Check{ + // The configuration check must run first, because it initializes data used by all other checks, + // and the credentials check second, because it initializes the Nutanix clients used by other checks. + n.configurationCheckFactory(cd), + n.credentialsCheckFactory(ctx, newClient, cd), + } + + checks = append(checks, n.vmImageChecksFactory(cd)...) + + // Add more checks here as needed. + + return checks +} diff --git a/pkg/webhook/preflight/nutanix/checker_test.go b/pkg/webhook/preflight/nutanix/checker_test.go new file mode 100644 index 000000000..0e50a6d3a --- /dev/null +++ b/pkg/webhook/preflight/nutanix/checker_test.go @@ -0,0 +1,171 @@ +// Copyright 2024 Nutanix. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package nutanix + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" + + prismgoclient "github.com/nutanix-cloud-native/prism-go-client" + + carenv1 "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/api/v1alpha1" + "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/webhook/preflight" +) + +type mockCheck struct { + name string + result preflight.CheckResult +} + +func (m *mockCheck) Name() string { + return m.name +} + +func (m *mockCheck) Run(ctx context.Context) preflight.CheckResult { + return m.result +} + +func TestNutanixChecker_Init(t *testing.T) { + tests := []struct { + name string + nutanixConfig *carenv1.NutanixClusterConfigSpec + workerNodeConfigs map[string]*carenv1.NutanixWorkerNodeConfigSpec + expectedCheckCount int + expectedFirstCheckName string + expectedSecondCheckName string + vmImageCheckCount int + }{ + { + name: "basic initialization with no configs", + nutanixConfig: nil, + workerNodeConfigs: nil, + expectedCheckCount: 2, // config check and credentials check + expectedFirstCheckName: "NutanixConfiguration", + expectedSecondCheckName: "NutanixCredentials", + vmImageCheckCount: 0, + }, + { + name: "initialization with control plane config", + nutanixConfig: &carenv1.NutanixClusterConfigSpec{ + ControlPlane: &carenv1.NutanixControlPlaneSpec{ + Nutanix: &carenv1.NutanixNodeSpec{}, + }, + }, + workerNodeConfigs: nil, + expectedCheckCount: 3, // config check, credentials check, 1 VM image check + expectedFirstCheckName: "NutanixConfiguration", + expectedSecondCheckName: "NutanixCredentials", + vmImageCheckCount: 1, + }, + { + name: "initialization with worker node configs", + nutanixConfig: nil, + workerNodeConfigs: map[string]*carenv1.NutanixWorkerNodeConfigSpec{ + "worker-1": { + Nutanix: &carenv1.NutanixNodeSpec{}, + }, + "worker-2": { + Nutanix: &carenv1.NutanixNodeSpec{}, + }, + }, + expectedCheckCount: 4, // config check, credentials check, 2 VM image checks + expectedFirstCheckName: "NutanixConfiguration", + expectedSecondCheckName: "NutanixCredentials", + vmImageCheckCount: 2, + }, + { + name: "initialization with both control plane and worker node configs", + nutanixConfig: &carenv1.NutanixClusterConfigSpec{ + ControlPlane: &carenv1.NutanixControlPlaneSpec{ + Nutanix: &carenv1.NutanixNodeSpec{}, + }, + }, + workerNodeConfigs: map[string]*carenv1.NutanixWorkerNodeConfigSpec{ + "worker-1": { + Nutanix: &carenv1.NutanixNodeSpec{}, + }, + }, + expectedCheckCount: 4, // config check, credentials check, 2 VM image checks (1 CP + 1 worker) + expectedFirstCheckName: "NutanixConfiguration", + expectedSecondCheckName: "NutanixCredentials", + vmImageCheckCount: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create the checker + checker := &nutanixChecker{} + + // Mock the sub-check functions to track their calls + configCheckCalled := false + credsCheckCalled := false + vmImageCheckCount := 0 + + checker.configurationCheckFactory = func(cd *checkDependencies) preflight.Check { + configCheckCalled = true + return &mockCheck{ + name: tt.expectedFirstCheckName, + result: preflight.CheckResult{Allowed: true}, + } + } + + checker.credentialsCheckFactory = func( + ctx context.Context, + nclientFactory func(prismgoclient.Credentials) (client, error), + cd *checkDependencies, + ) preflight.Check { + credsCheckCalled = true + return &mockCheck{ + name: tt.expectedSecondCheckName, + result: preflight.CheckResult{Allowed: true}, + } + } + + checker.vmImageChecksFactory = func(cd *checkDependencies) []preflight.Check { + checks := []preflight.Check{} + for i := 0; i < tt.vmImageCheckCount; i++ { + vmImageCheckCount++ + checks = append(checks, + &mockCheck{ + name: fmt.Sprintf("NutanixVMImage-%d", i), + result: preflight.CheckResult{ + Allowed: true, + }, + }, + ) + } + return checks + } + + // Call Init + ctx := context.Background() + checks := checker.Init(ctx, nil, &clusterv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cluster", + Namespace: "default", + }, + }) + + // Verify correct number of checks + assert.Len(t, checks, tt.expectedCheckCount) + + // Verify the sub-functions were called + assert.True(t, configCheckCalled, "initNutanixConfiguration should have been called") + assert.True(t, credsCheckCalled, "initCredentialsCheck should have been called") + assert.Equal(t, tt.vmImageCheckCount, vmImageCheckCount, "Wrong number of VM image checks") + + // Verify the first two checks when we have results + if len(checks) >= 2 { + assert.Equal(t, tt.expectedFirstCheckName, checks[0].Name()) + assert.Equal(t, tt.expectedSecondCheckName, checks[1].Name()) + } + }) + } +} diff --git a/pkg/webhook/preflight/nutanix/clients.go b/pkg/webhook/preflight/nutanix/clients.go new file mode 100644 index 000000000..c80bc111e --- /dev/null +++ b/pkg/webhook/preflight/nutanix/clients.go @@ -0,0 +1,92 @@ +// Copyright 2024 Nutanix. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package nutanix + +import ( + "context" + "fmt" + + vmmv4 "github.com/nutanix/ntnx-api-golang-clients/vmm-go-client/v4/models/vmm/v4/content" + + prismgoclient "github.com/nutanix-cloud-native/prism-go-client" + prismv3 "github.com/nutanix-cloud-native/prism-go-client/v3" + prismv4 "github.com/nutanix-cloud-native/prism-go-client/v4" +) + +// client contains methods to interact with Nutanix Prism v3 and v4 APIs. +type client interface { + GetCurrentLoggedInUser(ctx context.Context) (*prismv3.UserIntentResponse, error) + + GetImageById(id *string) (*vmmv4.GetImageApiResponse, error) + ListImages(page_ *int, + limit_ *int, + filter_ *string, + orderby_ *string, + select_ *string, + args ...map[string]interface{}, + ) ( + *vmmv4.ListImagesApiResponse, + error, + ) +} + +// clientWrapper implements the client interface and wraps both v3 and v4 clients. +type clientWrapper struct { + v3client *prismv3.Client + v4client *prismv4.Client +} + +var _ = client(&clientWrapper{}) + +func newClient( + credentials prismgoclient.Credentials, //nolint:gocritic // hugeParam is fine +) (client, error) { + v3c, err := prismv3.NewV3Client(credentials) + if err != nil { + return nil, fmt.Errorf("failed to create v3 client: %w", err) + } + + v4c, err := prismv4.NewV4Client(credentials) + if err != nil { + return nil, fmt.Errorf("failed to create v4 client: %w", err) + } + + return &clientWrapper{ + v3client: v3c, + v4client: v4c, + }, nil +} + +func (c *clientWrapper) GetCurrentLoggedInUser(ctx context.Context) (*prismv3.UserIntentResponse, error) { + return c.v3client.V3.GetCurrentLoggedInUser(ctx) +} + +func (c *clientWrapper) GetImageById(id *string) (*vmmv4.GetImageApiResponse, error) { + resp, err := c.v4client.ImagesApiInstance.GetImageById(id) + if err != nil { + return nil, err + } + return resp, nil +} + +func (c *clientWrapper) ListImages(page_ *int, + limit_ *int, + filter_ *string, + orderby_ *string, + select_ *string, + args ...map[string]interface{}, +) (*vmmv4.ListImagesApiResponse, error) { + resp, err := c.v4client.ImagesApiInstance.ListImages( + page_, + limit_, + filter_, + orderby_, + select_, + args..., + ) + if err != nil { + return nil, err + } + return resp, nil +} diff --git a/pkg/webhook/preflight/nutanix/clients_test.go b/pkg/webhook/preflight/nutanix/clients_test.go new file mode 100644 index 000000000..acf66f5f6 --- /dev/null +++ b/pkg/webhook/preflight/nutanix/clients_test.go @@ -0,0 +1,54 @@ +// Copyright 2024 Nutanix. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package nutanix + +import ( + "context" + + vmmv4 "github.com/nutanix/ntnx-api-golang-clients/vmm-go-client/v4/models/vmm/v4/content" + + prismv3 "github.com/nutanix-cloud-native/prism-go-client/v3" +) + +var _ = client(&mocknclient{}) + +// mocknclient is a mock implementation of the client interface for testing purposes. +type mocknclient struct { + user *prismv3.UserIntentResponse + err error + + getImageByIdFunc func( + uuid *string, + ) ( + *vmmv4.GetImageApiResponse, error, + ) + + listImagesFunc func( + page, + limit *int, + filter, + orderby, + select_ *string, + args ...map[string]interface{}, + ) ( + *vmmv4.ListImagesApiResponse, + error, + ) +} + +func (m *mocknclient) GetCurrentLoggedInUser(ctx context.Context) (*prismv3.UserIntentResponse, error) { + return m.user, m.err +} + +func (m *mocknclient) GetImageById(uuid *string) (*vmmv4.GetImageApiResponse, error) { + return m.getImageByIdFunc(uuid) +} + +func (m *mocknclient) ListImages( + page, limit *int, + filter, orderby, select_ *string, + args ...map[string]interface{}, +) (*vmmv4.ListImagesApiResponse, error) { + return m.listImagesFunc(page, limit, filter, orderby, select_) +} diff --git a/pkg/webhook/preflight/nutanix/credentials.go b/pkg/webhook/preflight/nutanix/credentials.go new file mode 100644 index 000000000..e6530a612 --- /dev/null +++ b/pkg/webhook/preflight/nutanix/credentials.go @@ -0,0 +1,190 @@ +// Copyright 2025 Nutanix. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package nutanix + +import ( + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + + prismgoclient "github.com/nutanix-cloud-native/prism-go-client" + prismcredentials "github.com/nutanix-cloud-native/prism-go-client/environment/credentials" + + "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/webhook/preflight" +) + +const credentialsSecretDataKey = "credentials" + +type credentialsCheck struct { + result preflight.CheckResult +} + +func (c *credentialsCheck) Name() string { + return "NutanixCredentials" +} + +func (c *credentialsCheck) Run(_ context.Context) preflight.CheckResult { + return c.result +} + +func newCredentialsCheck( + ctx context.Context, + nclientFactory func(prismgoclient.Credentials) (client, error), + cd *checkDependencies, +) preflight.Check { + cd.log.V(5).Info("Initializing Nutanix credentials check") + + credentialsCheck := &credentialsCheck{ + result: preflight.CheckResult{ + Allowed: true, + }, + } + + if cd.nutanixClusterConfigSpec == nil && len(cd.nutanixWorkerNodeConfigSpecByMachineDeploymentName) == 0 { + // If there is no Nutanix configuration at all, the credentials check is not needed. + return credentialsCheck + } + + // There is some Nutanix configuration, so the credentials check is needed. + // However, the credentials configuration is missing, so we cannot perform the check. + if cd.nutanixClusterConfigSpec == nil || cd.nutanixClusterConfigSpec.Nutanix == nil { + credentialsCheck.result.Allowed = false + credentialsCheck.result.Error = true + credentialsCheck.result.Causes = append(credentialsCheck.result.Causes, + preflight.Cause{ + Message: "Nutanix cluster configuration is not defined in the cluster spec", + Field: "cluster.spec.topology.variables[.name=clusterConfig].nutanix", + }, + ) + return credentialsCheck + } + + // Get the credentials data in order to initialize the credentials and clients. + prismCentralEndpointSpec := cd.nutanixClusterConfigSpec.Nutanix.PrismCentralEndpoint + + host, port, err := prismCentralEndpointSpec.ParseURL() + if err != nil { + // Should not happen if the cluster passed CEL validation rules. + credentialsCheck.result.Allowed = false + credentialsCheck.result.Error = true + credentialsCheck.result.Causes = append(credentialsCheck.result.Causes, + preflight.Cause{ + Message: fmt.Sprintf("failed to parse Prism Central endpoint URL: %s", err), + Field: "cluster.spec.topology.variables[.name=clusterConfig].nutanix.prismCentralEndpoint.url", + }, + ) + return credentialsCheck + } + + credentialsSecret := &corev1.Secret{} + err = cd.kclient.Get( + ctx, + types.NamespacedName{ + Namespace: cd.cluster.Namespace, + Name: prismCentralEndpointSpec.Credentials.SecretRef.Name, + }, + credentialsSecret, + ) + if err != nil { + credentialsCheck.result.Allowed = false + credentialsCheck.result.Error = true + credentialsCheck.result.Causes = append(credentialsCheck.result.Causes, + preflight.Cause{ + Message: fmt.Sprintf("failed to get Prism Central credentials Secret: %s", err), + Field: "cluster.spec.topology.variables[.name=clusterConfig].nutanix.prismCentralEndpoint.credentials.secretRef", + }, + ) + return credentialsCheck + } + + if len(credentialsSecret.Data) == 0 { + credentialsCheck.result.Allowed = false + credentialsCheck.result.Error = true + credentialsCheck.result.Causes = append(credentialsCheck.result.Causes, + preflight.Cause{ + Message: fmt.Sprintf( + "credentials Secret '%s' is empty", + prismCentralEndpointSpec.Credentials.SecretRef.Name, + ), + Field: "cluster.spec.topology.variables[.name=clusterConfig].nutanix.prismCentralEndpoint.credentials.secretRef", + }, + ) + return credentialsCheck + } + + data, ok := credentialsSecret.Data[credentialsSecretDataKey] + if !ok { + credentialsCheck.result.Allowed = false + credentialsCheck.result.Error = true + credentialsCheck.result.Causes = append(credentialsCheck.result.Causes, + preflight.Cause{ + Message: fmt.Sprintf( + "credentials Secret '%s' does not contain key '%s'", + prismCentralEndpointSpec.Credentials.SecretRef.Name, + credentialsSecretDataKey, + ), + Field: "cluster.spec.topology.variables[.name=clusterConfig].nutanix.prismCentralEndpoint.credentials.secretRef", + }, + ) + return credentialsCheck + } + + usernamePassword, err := prismcredentials.ParseCredentials(data) + if err != nil { + credentialsCheck.result.Allowed = false + credentialsCheck.result.Error = true + credentialsCheck.result.Causes = append(credentialsCheck.result.Causes, + preflight.Cause{ + Message: fmt.Sprintf("failed to parse Prism Central credentials: %s", err), + Field: "cluster.spec.topology.variables[.name=clusterConfig].nutanix.prismCentralEndpoint.credentials", + }, + ) + return credentialsCheck + } + + // Initialize the credentials. + credentials := prismgoclient.Credentials{ + Endpoint: fmt.Sprintf("%s:%d", host, port), + URL: fmt.Sprintf("https://%s:%d", host, port), + Username: usernamePassword.Username, + Password: usernamePassword.Password, + Insecure: prismCentralEndpointSpec.Insecure, + } + + // Initialize the Nutanix client. + nclient, err := nclientFactory(credentials) + if err != nil { + credentialsCheck.result.Allowed = false + credentialsCheck.result.Error = true + credentialsCheck.result.Causes = append(credentialsCheck.result.Causes, + preflight.Cause{ + Message: fmt.Sprintf("Failed to initialize Nutanix client: %s", err), + Field: "cluster.spec.topology.variables[.name=clusterConfig].nutanix.prismCentralEndpoint.credentials", + }, + ) + return credentialsCheck + } + + // Validate the credentials using an API call. + _, err = nclient.GetCurrentLoggedInUser(ctx) + if err != nil { + credentialsCheck.result.Allowed = false + credentialsCheck.result.Error = true + credentialsCheck.result.Causes = append(credentialsCheck.result.Causes, + preflight.Cause{ + Message: fmt.Sprintf("Failed to validate credentials using the v3 API client. "+ + "The URL and/or credentials may be incorrect. (Error: %q)", err), + Field: "cluster.spec.topology.variables[.name=clusterConfig].nutanix.prismCentralEndpoint", + }, + ) + return credentialsCheck + } + + // We initialized both clients, and verified the credentials using the v3 client. + cd.nclient = nclient + + return credentialsCheck +} diff --git a/pkg/webhook/preflight/nutanix/credentials_test.go b/pkg/webhook/preflight/nutanix/credentials_test.go new file mode 100644 index 000000000..41d909fb8 --- /dev/null +++ b/pkg/webhook/preflight/nutanix/credentials_test.go @@ -0,0 +1,216 @@ +// Copyright 2024 Nutanix. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package nutanix + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + prismgoclient "github.com/nutanix-cloud-native/prism-go-client" + + carenv1 "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/api/v1alpha1" +) + +func TestNewCredentialsCheck_Success(t *testing.T) { + cd := validCheckDependencies() + nclientFactory := func(_ prismgoclient.Credentials) (client, error) { + return &mocknclient{}, nil + } + check := newCredentialsCheck(context.Background(), nclientFactory, cd) + result := check.Run(context.Background()) + assert.True(t, result.Allowed) + assert.False(t, result.Error) + assert.Empty(t, result.Causes) +} + +func TestNewCredentialsCheck_NoNutanixConfig(t *testing.T) { + cd := validCheckDependencies() + cd.nutanixClusterConfigSpec = nil + nclientFactory := func(_ prismgoclient.Credentials) (client, error) { + return &mocknclient{}, nil + } + check := newCredentialsCheck(context.Background(), nclientFactory, cd) + result := check.Run(context.Background()) + assert.True(t, result.Allowed) + assert.False(t, result.Error) + assert.Empty(t, result.Causes) +} + +func TestNewCredentialsCheck_MissingNutanixField(t *testing.T) { + cd := validCheckDependencies() + cd.nutanixClusterConfigSpec.Nutanix = nil + nclientFactory := func(_ prismgoclient.Credentials) (client, error) { + return &mocknclient{}, nil + } + check := newCredentialsCheck(context.Background(), nclientFactory, cd) + result := check.Run(context.Background()) + assert.False(t, result.Allowed) + assert.True(t, result.Error) + assert.NotEmpty(t, result.Causes) + assert.Contains(t, result.Causes[0].Message, "Nutanix cluster configuration is not defined") +} + +func TestNewCredentialsCheck_InvalidURL(t *testing.T) { + cd := validCheckDependencies() + cd.nutanixClusterConfigSpec.Nutanix.PrismCentralEndpoint.URL = "not-a-url" + nclientFactory := func(_ prismgoclient.Credentials) (client, error) { + return &mocknclient{}, nil + } + check := newCredentialsCheck(context.Background(), nclientFactory, cd) + result := check.Run(context.Background()) + assert.False(t, result.Allowed) + assert.True(t, result.Error) + assert.Contains(t, result.Causes[0].Message, "failed to parse Prism Central endpoint URL") +} + +func TestNewCredentialsCheck_SecretNotFound(t *testing.T) { + cd := validCheckDependencies() + cd.kclient = fake.NewClientBuilder().Build() // no secret + nclientFactory := func(_ prismgoclient.Credentials) (client, error) { + return &mocknclient{}, nil + } + check := newCredentialsCheck(context.Background(), nclientFactory, cd) + result := check.Run(context.Background()) + assert.False(t, result.Allowed) + assert.True(t, result.Error) + assert.Contains(t, result.Causes[0].Message, "failed to get Prism Central credentials Secret") +} + +func TestNewCredentialsCheck_SecretEmpty(t *testing.T) { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ntnx-creds", + Namespace: "default", + }, + Data: map[string][]byte{}, + } + cd := validCheckDependencies() + cd.kclient = fake.NewClientBuilder().WithObjects(secret).Build() + nclientFactory := func(_ prismgoclient.Credentials) (client, error) { + return &mocknclient{}, nil + } + check := newCredentialsCheck(context.Background(), nclientFactory, cd) + result := check.Run(context.Background()) + assert.False(t, result.Allowed) + assert.True(t, result.Error) + assert.Contains(t, result.Causes[0].Message, "credentials Secret 'ntnx-creds' is empty") +} + +func TestNewCredentialsCheck_SecretMissingKey(t *testing.T) { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ntnx-creds", + Namespace: "default", + }, + Data: map[string][]byte{ + "not-credentials": []byte("foo"), + }, + } + cd := validCheckDependencies() + cd.kclient = fake.NewClientBuilder().WithObjects(secret).Build() + check := newCredentialsCheck(context.Background(), nil, cd) + result := check.Run(context.Background()) + assert.False(t, result.Allowed) + assert.True(t, result.Error) + assert.Contains(t, result.Causes[0].Message, "does not contain key 'credentials'") +} + +func TestNewCredentialsCheck_InvalidCredentialsFormat(t *testing.T) { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ntnx-creds", + Namespace: "default", + }, + Data: map[string][]byte{ + "credentials": []byte("not-a-valid-format"), + }, + } + cd := validCheckDependencies() + cd.kclient = fake.NewClientBuilder().WithObjects(secret).Build() + nclientFactory := func(_ prismgoclient.Credentials) (client, error) { + return &mocknclient{}, nil + } + check := newCredentialsCheck(context.Background(), nclientFactory, cd) + result := check.Run(context.Background()) + assert.False(t, result.Allowed) + assert.True(t, result.Error) + assert.Contains(t, result.Causes[0].Message, "failed to parse Prism Central credentials") +} + +func TestNewCredentialsCheck_FailedToCreateClient(t *testing.T) { + // Simulate a failure in creating the v4 client + nclientFactory := func(_ prismgoclient.Credentials) (client, error) { + return nil, assert.AnError + } + cd := validCheckDependencies() + check := newCredentialsCheck(context.Background(), nclientFactory, cd) + result := check.Run(context.Background()) + assert.False(t, result.Allowed) + assert.True(t, result.Error) + assert.Contains(t, result.Causes[0].Message, "Failed to initialize Nutanix client") +} + +func TestNewCredentialsCheck_FailedToGetCurrentLoggedInUser(t *testing.T) { + // Simulate a failure in getting the current logged-in user + nclientFactory := func(_ prismgoclient.Credentials) (client, error) { + return &mocknclient{err: assert.AnError}, nil + } + cd := validCheckDependencies() + check := newCredentialsCheck(context.Background(), nclientFactory, cd) + result := check.Run(context.Background()) + assert.False(t, result.Allowed) + assert.True(t, result.Error) + assert.Contains(t, result.Causes[0].Message, "Failed to validate credentials using the v3 API client.") +} + +func validCheckDependencies() *checkDependencies { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ntnx-creds", + Namespace: "default", + }, + Data: map[string][]byte{ + "credentials": []byte(`[ + { + "type": "basic_auth", + "data": { + "prismCentral": { + "username": "testuser", + "password": "testpassword" + } + } + } + ]`), + }, + } + + return &checkDependencies{ + kclient: fake.NewClientBuilder().WithObjects(secret).Build(), + cluster: &clusterv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cluster", + Namespace: "default", + }, + }, + nutanixClusterConfigSpec: &carenv1.NutanixClusterConfigSpec{ + Nutanix: &carenv1.NutanixSpec{ + PrismCentralEndpoint: carenv1.NutanixPrismCentralEndpointSpec{ + URL: "https://pc.example.com:9440", + Credentials: carenv1.NutanixPrismCentralEndpointCredentials{ + SecretRef: carenv1.LocalObjectReference{ + Name: "ntnx-creds", + }, + }, + }, + }, + }, + nutanixWorkerNodeConfigSpecByMachineDeploymentName: map[string]*carenv1.NutanixWorkerNodeConfigSpec{}, + } +} diff --git a/pkg/webhook/preflight/nutanix/image.go b/pkg/webhook/preflight/nutanix/image.go new file mode 100644 index 000000000..a8298139c --- /dev/null +++ b/pkg/webhook/preflight/nutanix/image.go @@ -0,0 +1,141 @@ +// Copyright 2025 Nutanix. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package nutanix + +import ( + "context" + "fmt" + + vmmv4 "github.com/nutanix/ntnx-api-golang-clients/vmm-go-client/v4/models/vmm/v4/content" + + capxv1 "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/api/external/github.com/nutanix-cloud-native/cluster-api-provider-nutanix/api/v1beta1" + carenv1 "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/api/v1alpha1" + "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/webhook/preflight" +) + +type imageCheck struct { + machineDetails *carenv1.NutanixMachineDetails + field string + nclient client +} + +func (c *imageCheck) Name() string { + return "NutanixVMImage" +} + +func (c *imageCheck) Run(ctx context.Context) preflight.CheckResult { + result := preflight.CheckResult{ + Allowed: false, + } + + if c.machineDetails.ImageLookup != nil { + result.Allowed = true + result.Warnings = append( + result.Warnings, + fmt.Sprintf("%s uses imageLookup, which is not yet supported by checks", c.field), + ) + return result + } + + if c.machineDetails.Image != nil { + images, err := getVMImages(c.nclient, c.machineDetails.Image) + if err != nil { + result.Allowed = false + result.Error = true + result.Causes = append(result.Causes, preflight.Cause{ + Message: fmt.Sprintf("failed to get VM Image: %s", err), + Field: c.field, + }) + return result + } + + if len(images) != 1 { + result.Allowed = false + result.Causes = append(result.Causes, preflight.Cause{ + Message: fmt.Sprintf("expected to find 1 VM Image, found %d", len(images)), + Field: c.field, + }) + return result + } + + // Found exactly one image. + result.Allowed = true + return result + } + + // Neither ImageLookup nor Image is specified. + return result +} + +func newVMImageChecks( + cd *checkDependencies, +) []preflight.Check { + checks := []preflight.Check{} + + if cd.nclient == nil { + return checks + } + + if cd.nutanixClusterConfigSpec != nil && cd.nutanixClusterConfigSpec.ControlPlane != nil && + cd.nutanixClusterConfigSpec.ControlPlane.Nutanix != nil { + checks = append(checks, + &imageCheck{ + machineDetails: &cd.nutanixClusterConfigSpec.ControlPlane.Nutanix.MachineDetails, + field: "cluster.spec.topology.variables[.name=clusterConfig]" + + ".value.nutanix.controlPlane.machineDetails", + nclient: cd.nclient, + }, + ) + } + + for mdName, nutanixWorkerNodeConfigSpec := range cd.nutanixWorkerNodeConfigSpecByMachineDeploymentName { + if nutanixWorkerNodeConfigSpec.Nutanix != nil { + checks = append(checks, + &imageCheck{ + machineDetails: &nutanixWorkerNodeConfigSpec.Nutanix.MachineDetails, + field: fmt.Sprintf("cluster.spec.topology.workers.machineDeployments[.name=%s]"+ + ".variables[.name=workerConfig].value.nutanix.machineDetails", mdName), + nclient: cd.nclient, + }, + ) + } + } + + return checks +} + +func getVMImages( + client client, + id *capxv1.NutanixResourceIdentifier, +) ([]vmmv4.Image, error) { + switch { + case id.IsUUID(): + resp, err := client.GetImageById(id.UUID) + if err != nil { + return nil, err + } + image, ok := resp.GetData().(vmmv4.Image) + if !ok { + return nil, fmt.Errorf("failed to get data returned by GetImageById") + } + return []vmmv4.Image{image}, nil + case id.IsName(): + filter_ := fmt.Sprintf("name eq '%s'", *id.Name) + resp, err := client.ListImages(nil, nil, &filter_, nil, nil) + if err != nil { + return nil, err + } + if resp == nil || resp.GetData() == nil { + // No images were returned. + return []vmmv4.Image{}, nil + } + images, ok := resp.GetData().([]vmmv4.Image) + if !ok { + return nil, fmt.Errorf("failed to get data returned by ListImages") + } + return images, nil + default: + return nil, fmt.Errorf("image identifier is missing both name and uuid") + } +} diff --git a/pkg/webhook/preflight/nutanix/image_test.go b/pkg/webhook/preflight/nutanix/image_test.go new file mode 100644 index 000000000..e8083873d --- /dev/null +++ b/pkg/webhook/preflight/nutanix/image_test.go @@ -0,0 +1,664 @@ +// Copyright 2025 Nutanix. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package nutanix + +import ( + "context" + "fmt" + "testing" + + "github.com/go-logr/logr/testr" + vmmv4 "github.com/nutanix/ntnx-api-golang-clients/vmm-go-client/v4/models/vmm/v4/content" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/utils/ptr" + + capxv1 "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/api/external/github.com/nutanix-cloud-native/cluster-api-provider-nutanix/api/v1beta1" + carenv1 "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/api/v1alpha1" + "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/webhook/preflight" +) + +func TestVMImageCheck(t *testing.T) { + testCases := []struct { + name string + nclient client + machineDetails *carenv1.NutanixMachineDetails + want preflight.CheckResult + }{ + { + name: "imageLookup not yet supported", + nclient: &mocknclient{}, + machineDetails: &carenv1.NutanixMachineDetails{ + ImageLookup: &capxv1.NutanixImageLookup{ + Format: ptr.To("test-format"), + BaseOS: "test-baseos", + }, + }, + want: preflight.CheckResult{ + Allowed: true, + Warnings: []string{ + "test-field uses imageLookup, which is not yet supported by checks", + }, + }, + }, + { + name: "image found by uuid", + nclient: &mocknclient{ + getImageByIdFunc: func(uuid *string) (*vmmv4.GetImageApiResponse, error) { + assert.Equal(t, "test-uuid", *uuid) + resp := &vmmv4.GetImageApiResponse{} + err := resp.SetData(vmmv4.Image{ + ObjectType_: ptr.To("vmm.v4.content.Image"), + ExtId: ptr.To("test-uuid"), + }) + require.NoError(t, err) + return resp, nil + }, + }, + machineDetails: &carenv1.NutanixMachineDetails{ + Image: &capxv1.NutanixResourceIdentifier{ + Type: capxv1.NutanixIdentifierUUID, + UUID: ptr.To("test-uuid"), + }, + }, + want: preflight.CheckResult{ + Allowed: true, + }, + }, + { + name: "image found by name", + nclient: &mocknclient{ + listImagesFunc: func(page, + limit *int, + filter, + orderby, + select_ *string, + args ...map[string]interface{}, + ) ( + *vmmv4.ListImagesApiResponse, + error, + ) { + resp := &vmmv4.ListImagesApiResponse{} + err := resp.SetData([]vmmv4.Image{ + { + Name: ptr.To("test-image-name"), + }, + }) + require.NoError(t, err) + return resp, nil + }, + }, + machineDetails: &carenv1.NutanixMachineDetails{ + Image: &capxv1.NutanixResourceIdentifier{ + Type: capxv1.NutanixIdentifierName, + Name: ptr.To("test-image-name"), + }, + }, + want: preflight.CheckResult{ + Allowed: true, + }, + }, + { + name: "image not found by name", + nclient: &mocknclient{ + listImagesFunc: func(page, + limit *int, + filter, + orderby, + select_ *string, + args ...map[string]interface{}, + ) ( + *vmmv4.ListImagesApiResponse, + error, + ) { + return &vmmv4.ListImagesApiResponse{}, nil + }, + }, + machineDetails: &carenv1.NutanixMachineDetails{ + Image: &capxv1.NutanixResourceIdentifier{ + Type: capxv1.NutanixIdentifierName, + Name: ptr.To("test-non-existent-image"), + }, + }, + want: preflight.CheckResult{ + Allowed: false, + Causes: []preflight.Cause{ + { + Message: "expected to find 1 VM Image, found 0", + Field: "test-field", + }, + }, + }, + }, + { + name: "multiple images found by name", + nclient: &mocknclient{ + listImagesFunc: func(page, + limit *int, + filter, + orderby, + select_ *string, + args ...map[string]interface{}, + ) ( + *vmmv4.ListImagesApiResponse, + error, + ) { + resp := &vmmv4.ListImagesApiResponse{} + err := resp.SetData([]vmmv4.Image{ + { + Name: ptr.To("test-duplicate-image"), + }, + { + Name: ptr.To("test-duplicate-image"), + }, + }) + require.NoError(t, err) + return resp, nil + }, + }, + machineDetails: &carenv1.NutanixMachineDetails{ + Image: &capxv1.NutanixResourceIdentifier{ + Type: capxv1.NutanixIdentifierName, + Name: ptr.To("test-duplicate-image"), + }, + }, + want: preflight.CheckResult{ + Allowed: false, + Causes: []preflight.Cause{ + { + Message: "expected to find 1 VM Image, found 2", + Field: "test-field", + }, + }, + }, + }, + { + name: "error getting image by id", + nclient: &mocknclient{ + getImageByIdFunc: func(uuid *string) (*vmmv4.GetImageApiResponse, error) { + return nil, fmt.Errorf("api error") + }, + }, + machineDetails: &carenv1.NutanixMachineDetails{ + Image: &capxv1.NutanixResourceIdentifier{ + Type: capxv1.NutanixIdentifierUUID, + UUID: ptr.To("test-uuid"), + }, + }, + want: preflight.CheckResult{ + Allowed: false, + Error: true, + Causes: []preflight.Cause{ + { + Message: "failed to get VM Image: api error", + Field: "test-field", + }, + }, + }, + }, + { + name: "error listing images", + nclient: &mocknclient{ + listImagesFunc: func(page, + limit *int, + filter, + orderby, + select_ *string, + args ...map[string]interface{}, + ) ( + *vmmv4.ListImagesApiResponse, + error, + ) { + return nil, fmt.Errorf("api error") + }, + }, + machineDetails: &carenv1.NutanixMachineDetails{ + Image: &capxv1.NutanixResourceIdentifier{ + Type: capxv1.NutanixIdentifierName, + Name: ptr.To("test-image"), + }, + }, + want: preflight.CheckResult{ + Allowed: false, + Error: true, + Causes: []preflight.Cause{ + { + Message: "failed to get VM Image: api error", + Field: "test-field", + }, + }, + }, + }, + { + name: "neither image nor imageLookup specified", + nclient: &mocknclient{}, + machineDetails: &carenv1.NutanixMachineDetails{ + // both Image and ImageLookup are nil + }, + want: preflight.CheckResult{ + Allowed: false, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Create the check + check := &imageCheck{ + machineDetails: tc.machineDetails, + field: "test-field", + nclient: tc.nclient, + } + + // Execute the check + got := check.Run(context.Background()) + + // Verify the result + assert.Equal(t, tc.want.Allowed, got.Allowed) + assert.Equal(t, tc.want.Error, got.Error) + assert.Equal(t, tc.want.Causes, got.Causes) + }) + } +} + +func TestGetVMImages(t *testing.T) { + testCases := []struct { + name string + client *mocknclient + id *capxv1.NutanixResourceIdentifier + want []vmmv4.Image + wantErr bool + errorMsg string + }{ + { + name: "get image by uuid success", + client: &mocknclient{ + getImageByIdFunc: func(uuid *string) (*vmmv4.GetImageApiResponse, error) { + assert.Equal(t, "test-uuid", *uuid) + resp := &vmmv4.GetImageApiResponse{} + err := resp.SetData(vmmv4.Image{ + ObjectType_: ptr.To("vmm.v4.content.Image"), + ExtId: ptr.To("test-uuid"), + }) + require.NoError(t, err) + return resp, nil + }, + }, + id: &capxv1.NutanixResourceIdentifier{ + Type: capxv1.NutanixIdentifierUUID, + UUID: ptr.To("test-uuid"), + }, + want: []vmmv4.Image{ + { + ObjectType_: ptr.To("vmm.v4.content.Image"), + ExtId: ptr.To("test-uuid"), + }, + }, + wantErr: false, + }, + { + name: "get image by name success", + client: &mocknclient{ + listImagesFunc: func(page, + limit *int, + filter, + orderby, + select_ *string, + args ...map[string]interface{}, + ) ( + *vmmv4.ListImagesApiResponse, + error, + ) { + assert.NotNil(t, filter) + assert.Equal(t, "name eq 'test-name'", *filter) + resp := &vmmv4.ListImagesApiResponse{} + err := resp.SetData([]vmmv4.Image{ + { + Name: ptr.To("test-name"), + }, + }) + require.NoError(t, err) + return resp, nil + }, + }, + id: &capxv1.NutanixResourceIdentifier{ + Type: capxv1.NutanixIdentifierName, + Name: ptr.To("test-name"), + }, + want: []vmmv4.Image{ + { + Name: ptr.To("test-name"), + }, + }, + wantErr: false, + }, + { + name: "get image by uuid error", + client: &mocknclient{ + getImageByIdFunc: func(uuid *string) (*vmmv4.GetImageApiResponse, error) { + return nil, fmt.Errorf("api error") + }, + }, + id: &capxv1.NutanixResourceIdentifier{ + Type: capxv1.NutanixIdentifierUUID, + UUID: ptr.To("test-uuid"), + }, + wantErr: true, + errorMsg: "api error", + }, + { + name: "get image by name error", + client: &mocknclient{ + listImagesFunc: func(page, + limit *int, + filter, + orderby, + select_ *string, + args ...map[string]interface{}, + ) ( + *vmmv4.ListImagesApiResponse, + error, + ) { + return nil, fmt.Errorf("api error") + }, + }, + id: &capxv1.NutanixResourceIdentifier{ + Type: capxv1.NutanixIdentifierName, + Name: ptr.To("test-name"), + }, + wantErr: true, + errorMsg: "api error", + }, + { + name: "neither name nor uuid specified", + client: &mocknclient{}, + id: &capxv1.NutanixResourceIdentifier{ + // Both Name and UUID are not set + }, + wantErr: true, + errorMsg: "image identifier is missing both name and uuid", + }, + { + name: "invalid data from GetImageById", + client: &mocknclient{ + getImageByIdFunc: func(uuid *string) (*vmmv4.GetImageApiResponse, error) { + return &vmmv4.GetImageApiResponse{ + Data: &vmmv4.OneOfGetImageApiResponseData{ + ObjectType_: ptr.To("wrong-type"), + }, + }, nil + }, + }, + id: &capxv1.NutanixResourceIdentifier{ + Type: capxv1.NutanixIdentifierUUID, + UUID: ptr.To("test-uuid"), + }, + wantErr: true, + errorMsg: "failed to get data returned by GetImageById", + }, + { + name: "empty response from ListImages", + client: &mocknclient{ + listImagesFunc: func(page, + limit *int, + filter, + orderby, + select_ *string, + args ...map[string]interface{}, + ) ( + *vmmv4.ListImagesApiResponse, + error, + ) { + return &vmmv4.ListImagesApiResponse{ + Data: nil, // Empty data + }, nil + }, + }, + id: &capxv1.NutanixResourceIdentifier{ + Type: capxv1.NutanixIdentifierName, + Name: ptr.To("test-name"), + }, + want: []vmmv4.Image{}, + wantErr: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got, err := getVMImages(tc.client, tc.id) + + if tc.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.errorMsg) + } else { + require.NoError(t, err) + assert.Equal(t, tc.want, got) + } + }) + } +} + +func TestNewVMImageChecks(t *testing.T) { + testCases := []struct { + name string + nutanixClusterConfigSpec *carenv1.NutanixClusterConfigSpec + nutanixWorkerNodeConfigSpecByMDName map[string]*carenv1.NutanixWorkerNodeConfigSpec + nclient client + expectedChecks int + expectedControlPlaneCheckFieldIncluded bool + expectedWorkerNodeCheckFieldPatternExists bool + }{ + { + name: "client not initialized", + nutanixClusterConfigSpec: nil, + nutanixWorkerNodeConfigSpecByMDName: nil, + nclient: nil, + expectedChecks: 0, + expectedControlPlaneCheckFieldIncluded: false, + expectedWorkerNodeCheckFieldPatternExists: false, + }, + { + name: "no nutanix configuration", + nutanixClusterConfigSpec: nil, + nutanixWorkerNodeConfigSpecByMDName: nil, + nclient: &mocknclient{}, + expectedChecks: 0, + expectedControlPlaneCheckFieldIncluded: false, + expectedWorkerNodeCheckFieldPatternExists: false, + }, + { + name: "control plane configuration only", + nutanixClusterConfigSpec: &carenv1.NutanixClusterConfigSpec{ + ControlPlane: &carenv1.NutanixControlPlaneSpec{ + Nutanix: &carenv1.NutanixNodeSpec{ + MachineDetails: carenv1.NutanixMachineDetails{ + Image: &capxv1.NutanixResourceIdentifier{ + Type: capxv1.NutanixIdentifierUUID, + UUID: ptr.To("test-uuid"), + }, + }, + }, + }, + }, + nutanixWorkerNodeConfigSpecByMDName: nil, + nclient: &mocknclient{ + getImageByIdFunc: func(uuid *string) (*vmmv4.GetImageApiResponse, error) { + assert.Equal(t, "test-uuid", *uuid) + resp := &vmmv4.GetImageApiResponse{} + err := resp.SetData(vmmv4.Image{ + ObjectType_: ptr.To("vmm.v4.content.Image"), + ExtId: ptr.To("test-uuid"), + }) + require.NoError(t, err) + return resp, nil + }, + }, + expectedChecks: 1, + expectedControlPlaneCheckFieldIncluded: true, + expectedWorkerNodeCheckFieldPatternExists: false, + }, + { + name: "worker nodes configuration only", + nutanixClusterConfigSpec: nil, + nutanixWorkerNodeConfigSpecByMDName: map[string]*carenv1.NutanixWorkerNodeConfigSpec{ + "worker-1": { + Nutanix: &carenv1.NutanixNodeSpec{ + MachineDetails: carenv1.NutanixMachineDetails{ + Image: &capxv1.NutanixResourceIdentifier{ + Type: capxv1.NutanixIdentifierUUID, + UUID: ptr.To("test-uuid"), + }, + }, + }, + }, + }, + nclient: &mocknclient{ + getImageByIdFunc: func(uuid *string) (*vmmv4.GetImageApiResponse, error) { + assert.Equal(t, "test-uuid", *uuid) + resp := &vmmv4.GetImageApiResponse{} + err := resp.SetData(vmmv4.Image{ + ObjectType_: ptr.To("vmm.v4.content.Image"), + ExtId: ptr.To("test-uuid"), + }) + require.NoError(t, err) + return resp, nil + }, + }, + expectedChecks: 1, + expectedControlPlaneCheckFieldIncluded: false, + expectedWorkerNodeCheckFieldPatternExists: true, + }, + { + name: "both control plane and worker nodes configuration", + nutanixClusterConfigSpec: &carenv1.NutanixClusterConfigSpec{ + ControlPlane: &carenv1.NutanixControlPlaneSpec{ + Nutanix: &carenv1.NutanixNodeSpec{ + MachineDetails: carenv1.NutanixMachineDetails{ + Image: &capxv1.NutanixResourceIdentifier{ + Type: capxv1.NutanixIdentifierUUID, + UUID: ptr.To("test-uuid"), + }, + }, + }, + }, + }, + nutanixWorkerNodeConfigSpecByMDName: map[string]*carenv1.NutanixWorkerNodeConfigSpec{ + "worker-1": { + Nutanix: &carenv1.NutanixNodeSpec{ + MachineDetails: carenv1.NutanixMachineDetails{ + Image: &capxv1.NutanixResourceIdentifier{ + Type: capxv1.NutanixIdentifierUUID, + UUID: ptr.To("test-uuid"), + }, + }, + }, + }, + "worker-2": { + Nutanix: &carenv1.NutanixNodeSpec{ + MachineDetails: carenv1.NutanixMachineDetails{ + Image: &capxv1.NutanixResourceIdentifier{ + Type: capxv1.NutanixIdentifierUUID, + UUID: ptr.To("test-uuid"), + }, + }, + }, + }, + }, + nclient: &mocknclient{ + getImageByIdFunc: func(uuid *string) (*vmmv4.GetImageApiResponse, error) { + assert.Equal(t, "test-uuid", *uuid) + resp := &vmmv4.GetImageApiResponse{} + err := resp.SetData(vmmv4.Image{ + ObjectType_: ptr.To("vmm.v4.content.Image"), + ExtId: ptr.To("test-uuid"), + }) + require.NoError(t, err) + return resp, nil + }, + }, + expectedChecks: 3, // 1 control plane + 2 workers + expectedControlPlaneCheckFieldIncluded: true, + expectedWorkerNodeCheckFieldPatternExists: true, + }, + { + name: "worker with nil Nutanix config", + nutanixClusterConfigSpec: nil, + nutanixWorkerNodeConfigSpecByMDName: map[string]*carenv1.NutanixWorkerNodeConfigSpec{ + "worker-1": { + Nutanix: nil, + }, + "worker-2": { + Nutanix: &carenv1.NutanixNodeSpec{ + MachineDetails: carenv1.NutanixMachineDetails{ + Image: &capxv1.NutanixResourceIdentifier{ + Type: capxv1.NutanixIdentifierUUID, + UUID: ptr.To("test-uuid"), + }, + }, + }, + }, + }, + nclient: &mocknclient{ + getImageByIdFunc: func(uuid *string) (*vmmv4.GetImageApiResponse, error) { + assert.Equal(t, "test-uuid", *uuid) + resp := &vmmv4.GetImageApiResponse{} + err := resp.SetData(vmmv4.Image{ + ObjectType_: ptr.To("vmm.v4.content.Image"), + ExtId: ptr.To("test-uuid"), + }) + require.NoError(t, err) + return resp, nil + }, + }, + expectedChecks: 1, // only worker-2 + expectedControlPlaneCheckFieldIncluded: false, + expectedWorkerNodeCheckFieldPatternExists: true, + }, + { + name: "control plane with nil Nutanix config", + nutanixClusterConfigSpec: &carenv1.NutanixClusterConfigSpec{ + ControlPlane: &carenv1.NutanixControlPlaneSpec{ + Nutanix: nil, // null nutanix config + }, + }, + nutanixWorkerNodeConfigSpecByMDName: nil, + expectedChecks: 0, + expectedControlPlaneCheckFieldIncluded: false, + expectedWorkerNodeCheckFieldPatternExists: false, + }, + { + name: "null control plane config", + nutanixClusterConfigSpec: &carenv1.NutanixClusterConfigSpec{ + ControlPlane: nil, // null control plane + }, + nutanixWorkerNodeConfigSpecByMDName: nil, + nclient: &mocknclient{}, + expectedChecks: 0, + expectedControlPlaneCheckFieldIncluded: false, + expectedWorkerNodeCheckFieldPatternExists: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + cd := &checkDependencies{ + nutanixClusterConfigSpec: tc.nutanixClusterConfigSpec, + nutanixWorkerNodeConfigSpecByMachineDeploymentName: tc.nutanixWorkerNodeConfigSpecByMDName, + nclient: tc.nclient, + log: testr.New(t), + } + + // Call the method under test + checks := newVMImageChecks(cd) + + // Verify number of checks + assert.Len(t, checks, tc.expectedChecks) + + results := make([]preflight.CheckResult, len(checks)) + for i, check := range checks { + results[i] = check.Run(context.Background()) + } + }) + } +} diff --git a/pkg/webhook/preflight/nutanix/specs.go b/pkg/webhook/preflight/nutanix/specs.go new file mode 100644 index 000000000..cd802d810 --- /dev/null +++ b/pkg/webhook/preflight/nutanix/specs.go @@ -0,0 +1,109 @@ +// Copyright 2025 Nutanix. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package nutanix + +import ( + "context" + "fmt" + + carenv1 "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/api/v1alpha1" + "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/api/variables" + "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/webhook/preflight" +) + +type configurationCheck struct { + result preflight.CheckResult +} + +func (c *configurationCheck) Name() string { + return "NutanixConfiguration" +} + +func (c *configurationCheck) Run(_ context.Context) preflight.CheckResult { + return c.result +} + +func newConfigurationCheck( + cd *checkDependencies, +) preflight.Check { + cd.log.V(5).Info("Initializing Nutanix configuration check") + + configurationCheck := &configurationCheck{ + result: preflight.CheckResult{ + Allowed: true, + }, + } + + nutanixClusterConfigSpec := &carenv1.NutanixClusterConfigSpec{} + err := variables.UnmarshalClusterVariable( + variables.GetClusterVariableByName( + carenv1.ClusterConfigVariableName, + cd.cluster.Spec.Topology.Variables, + ), + nutanixClusterConfigSpec, + ) + if err != nil { + // Should not happen if the cluster passed CEL validation rules. + configurationCheck.result.Allowed = false + configurationCheck.result.Error = true + configurationCheck.result.Causes = append(configurationCheck.result.Causes, + preflight.Cause{ + Message: fmt.Sprintf("Failed to unmarshal cluster variable %s: %s", + carenv1.ClusterConfigVariableName, + err, + ), + Field: "cluster.spec.topology.variables[.name=clusterConfig].nutanix", + }, + ) + } + + // Save the NutanixClusterConfigSpec only if it contains Nutanix configuration. + if nutanixClusterConfigSpec.Nutanix != nil || + (nutanixClusterConfigSpec.ControlPlane != nil && nutanixClusterConfigSpec.ControlPlane.Nutanix != nil) { + cd.nutanixClusterConfigSpec = nutanixClusterConfigSpec + } + + nutanixWorkerNodeConfigSpecByMachineDeploymentName := make(map[string]*carenv1.NutanixWorkerNodeConfigSpec) + if cd.cluster.Spec.Topology.Workers != nil { + for i := range cd.cluster.Spec.Topology.Workers.MachineDeployments { + md := &cd.cluster.Spec.Topology.Workers.MachineDeployments[i] + if md.Variables == nil { + continue + } + nutanixWorkerNodeConfigSpec := &carenv1.NutanixWorkerNodeConfigSpec{} + err := variables.UnmarshalClusterVariable( + variables.GetClusterVariableByName(carenv1.WorkerConfigVariableName, md.Variables.Overrides), + nutanixWorkerNodeConfigSpec, + ) + if err != nil { + // Should not happen if the cluster passed CEL validation rules. + configurationCheck.result.Allowed = false + configurationCheck.result.Error = true + configurationCheck.result.Causes = append(configurationCheck.result.Causes, + preflight.Cause{ + Message: fmt.Sprintf("Failed to unmarshal topology machineDeployment variable %s: %s", + carenv1.WorkerConfigVariableName, + err, + ), + Field: fmt.Sprintf( + "cluster.spec.topology.workers.machineDeployments[.name=%s]"+ + ".variables[.name=workerConfig].value.nutanix.machineDetails", + md.Name, + ), + }, + ) + } + // Save the NutanixWorkerNodeConfigSpec only if it contains Nutanix configuration. + if nutanixWorkerNodeConfigSpec.Nutanix != nil { + nutanixWorkerNodeConfigSpecByMachineDeploymentName[md.Name] = nutanixWorkerNodeConfigSpec + } + } + } + // Save the NutanixWorkerNodeConfigSpecByMachineDeploymentName only if it contains at least one Nutanix configuration. + if len(nutanixWorkerNodeConfigSpecByMachineDeploymentName) > 0 { + cd.nutanixWorkerNodeConfigSpecByMachineDeploymentName = nutanixWorkerNodeConfigSpecByMachineDeploymentName + } + + return configurationCheck +} diff --git a/pkg/webhook/preflight/nutanix/specs_test.go b/pkg/webhook/preflight/nutanix/specs_test.go new file mode 100644 index 000000000..a5ddf2711 --- /dev/null +++ b/pkg/webhook/preflight/nutanix/specs_test.go @@ -0,0 +1,376 @@ +// Copyright 2025 Nutanix. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package nutanix + +import ( + "context" + "testing" + + "github.com/go-logr/logr/testr" + "github.com/stretchr/testify/assert" + v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" + + carenv1 "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/api/v1alpha1" + "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/webhook/preflight" +) + +func TestNewConfigurationCheck(t *testing.T) { + tests := []struct { + name string + cluster *clusterv1.Cluster + expectedResult preflight.CheckResult + expectedNutanixClusterConfigSpec bool + expectedWorkerNodeConfigSpecMapNotEmpty bool + expectedWorkerNodeConfigSpecMapEntryCount int + }{ + { + name: "valid cluster config", + cluster: &clusterv1.Cluster{ + Spec: clusterv1.ClusterSpec{ + Topology: &clusterv1.Topology{ + Variables: []clusterv1.ClusterVariable{ + { + Name: carenv1.ClusterConfigVariableName, + Value: v1.JSON{ + Raw: []byte(`{"nutanix": {"prismCentral": {"address": "pc.example.com"}}}`), + }, + }, + }, + }, + }, + }, + expectedResult: preflight.CheckResult{ + Allowed: true, + }, + expectedNutanixClusterConfigSpec: true, + expectedWorkerNodeConfigSpecMapNotEmpty: false, + expectedWorkerNodeConfigSpecMapEntryCount: 0, + }, + { + name: "valid control plane config", + cluster: &clusterv1.Cluster{ + Spec: clusterv1.ClusterSpec{ + Topology: &clusterv1.Topology{ + Variables: []clusterv1.ClusterVariable{ + { + Name: carenv1.ClusterConfigVariableName, + Value: v1.JSON{ + Raw: []byte( + `{"controlPlane": {"nutanix": {"prismElement": {"address": "pe.example.com"}}}}`, + ), + }, + }, + }, + }, + }, + }, + expectedResult: preflight.CheckResult{ + Allowed: true, + }, + expectedNutanixClusterConfigSpec: true, + expectedWorkerNodeConfigSpecMapNotEmpty: false, + expectedWorkerNodeConfigSpecMapEntryCount: 0, + }, + { + name: "valid worker config", + cluster: &clusterv1.Cluster{ + Spec: clusterv1.ClusterSpec{ + Topology: &clusterv1.Topology{ + Variables: []clusterv1.ClusterVariable{ + { + Name: carenv1.ClusterConfigVariableName, + Value: v1.JSON{ + Raw: []byte(`{}`), + }, + }, + }, + Workers: &clusterv1.WorkersTopology{ + MachineDeployments: []clusterv1.MachineDeploymentTopology{ + { + Name: "md-0", + Variables: &clusterv1.MachineDeploymentVariables{ + Overrides: []clusterv1.ClusterVariable{ + { + Name: carenv1.WorkerConfigVariableName, + Value: v1.JSON{ + Raw: []byte( + `{"nutanix": {"prismElement": {"address": "pe.example.com"}}}`, + ), + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + expectedResult: preflight.CheckResult{ + Allowed: true, + }, + expectedNutanixClusterConfigSpec: false, + expectedWorkerNodeConfigSpecMapNotEmpty: true, + expectedWorkerNodeConfigSpecMapEntryCount: 1, + }, + { + name: "invalid cluster config", + cluster: &clusterv1.Cluster{ + Spec: clusterv1.ClusterSpec{ + Topology: &clusterv1.Topology{ + Variables: []clusterv1.ClusterVariable{ + { + Name: carenv1.ClusterConfigVariableName, + Value: v1.JSON{ + Raw: []byte(`{invalid-json`), + }, + }, + }, + }, + }, + }, + expectedResult: preflight.CheckResult{ + Allowed: false, + Error: true, + Causes: []preflight.Cause{ + { + Message: "Failed to unmarshal cluster variable clusterConfig: failed to unmarshal json:" + + " invalid character 'i' looking for beginning of object key string", + Field: "cluster.spec.topology.variables[.name=clusterConfig].nutanix", + }, + }, + }, + expectedNutanixClusterConfigSpec: false, + expectedWorkerNodeConfigSpecMapNotEmpty: false, + expectedWorkerNodeConfigSpecMapEntryCount: 0, + }, + { + name: "invalid worker config", + cluster: &clusterv1.Cluster{ + Spec: clusterv1.ClusterSpec{ + Topology: &clusterv1.Topology{ + Variables: []clusterv1.ClusterVariable{ + { + Name: carenv1.ClusterConfigVariableName, + Value: v1.JSON{ + Raw: []byte(`{}`), + }, + }, + }, + Workers: &clusterv1.WorkersTopology{ + MachineDeployments: []clusterv1.MachineDeploymentTopology{ + { + Name: "md-0", + Variables: &clusterv1.MachineDeploymentVariables{ + Overrides: []clusterv1.ClusterVariable{ + { + Name: carenv1.WorkerConfigVariableName, + Value: v1.JSON{ + Raw: []byte(`{invalid-json`), + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + expectedResult: preflight.CheckResult{ + Allowed: false, + Error: true, + Causes: []preflight.Cause{ + { + Message: "Failed to unmarshal topology machineDeployment variable workerConfig:" + + " failed to unmarshal json: invalid character 'i' looking for beginning of object key string", + Field: "cluster.spec.topology.workers.machineDeployments[.name=md-0]." + + "variables[.name=workerConfig].value.nutanix.machineDetails", + }, + }, + }, + expectedNutanixClusterConfigSpec: false, + expectedWorkerNodeConfigSpecMapNotEmpty: false, + expectedWorkerNodeConfigSpecMapEntryCount: 0, + }, + { + name: "no nutanix config", + cluster: &clusterv1.Cluster{ + Spec: clusterv1.ClusterSpec{ + Topology: &clusterv1.Topology{ + Variables: []clusterv1.ClusterVariable{ + { + Name: carenv1.ClusterConfigVariableName, + Value: v1.JSON{ + Raw: []byte(`{}`), + }, + }, + }, + }, + }, + }, + expectedResult: preflight.CheckResult{ + Allowed: true, + }, + expectedNutanixClusterConfigSpec: false, + expectedWorkerNodeConfigSpecMapNotEmpty: false, + expectedWorkerNodeConfigSpecMapEntryCount: 0, + }, + { + name: "multiple worker configs", + cluster: &clusterv1.Cluster{ + Spec: clusterv1.ClusterSpec{ + Topology: &clusterv1.Topology{ + Variables: []clusterv1.ClusterVariable{ + { + Name: carenv1.ClusterConfigVariableName, + Value: v1.JSON{ + Raw: []byte(`{}`), + }, + }, + }, + Workers: &clusterv1.WorkersTopology{ + MachineDeployments: []clusterv1.MachineDeploymentTopology{ + { + Name: "md-0", + Variables: &clusterv1.MachineDeploymentVariables{ + Overrides: []clusterv1.ClusterVariable{ + { + Name: carenv1.WorkerConfigVariableName, + Value: v1.JSON{ + Raw: []byte( + `{"nutanix": {"prismElement": {"address": "pe1.example.com"}}}`, + ), + }, + }, + }, + }, + }, + { + Name: "md-1", + Variables: &clusterv1.MachineDeploymentVariables{ + Overrides: []clusterv1.ClusterVariable{ + { + Name: carenv1.WorkerConfigVariableName, + Value: v1.JSON{ + Raw: []byte( + `{"nutanix": {"prismElement": {"address": "pe2.example.com"}}}`, + ), + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + expectedResult: preflight.CheckResult{ + Allowed: true, + }, + expectedNutanixClusterConfigSpec: false, + expectedWorkerNodeConfigSpecMapNotEmpty: true, + expectedWorkerNodeConfigSpecMapEntryCount: 2, + }, + { + name: "worker config without nutanix field", + cluster: &clusterv1.Cluster{ + Spec: clusterv1.ClusterSpec{ + Topology: &clusterv1.Topology{ + Variables: []clusterv1.ClusterVariable{ + { + Name: carenv1.ClusterConfigVariableName, + Value: v1.JSON{ + Raw: []byte(`{}`), + }, + }, + }, + Workers: &clusterv1.WorkersTopology{ + MachineDeployments: []clusterv1.MachineDeploymentTopology{ + { + Name: "md-0", + Variables: &clusterv1.MachineDeploymentVariables{ + Overrides: []clusterv1.ClusterVariable{ + { + Name: carenv1.WorkerConfigVariableName, + Value: v1.JSON{ + Raw: []byte(`{"someOtherField": true}`), + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + expectedResult: preflight.CheckResult{ + Allowed: true, + }, + expectedNutanixClusterConfigSpec: false, + expectedWorkerNodeConfigSpecMapNotEmpty: false, + expectedWorkerNodeConfigSpecMapEntryCount: 0, + }, + { + name: "machineDeployment without variables", + cluster: &clusterv1.Cluster{ + Spec: clusterv1.ClusterSpec{ + Topology: &clusterv1.Topology{ + Variables: []clusterv1.ClusterVariable{ + { + Name: carenv1.ClusterConfigVariableName, + Value: v1.JSON{ + Raw: []byte(`{}`), + }, + }, + }, + Workers: &clusterv1.WorkersTopology{ + MachineDeployments: []clusterv1.MachineDeploymentTopology{ + { + Name: "md-0", + }, + }, + }, + }, + }, + }, + expectedResult: preflight.CheckResult{ + Allowed: true, + }, + expectedNutanixClusterConfigSpec: false, + expectedWorkerNodeConfigSpecMapNotEmpty: false, + expectedWorkerNodeConfigSpecMapEntryCount: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cd := &checkDependencies{ + cluster: tt.cluster, + log: testr.New(t), + } + + check := newConfigurationCheck(cd) + result := check.Run(context.Background()) + + assert.Equal(t, tt.expectedResult, result) + + hasNutanixClusterConfigSpec := cd.nutanixClusterConfigSpec != nil + assert.Equal(t, tt.expectedNutanixClusterConfigSpec, hasNutanixClusterConfigSpec) + + hasWorkerNodeConfigSpecMap := cd.nutanixWorkerNodeConfigSpecByMachineDeploymentName != nil + assert.Equal(t, tt.expectedWorkerNodeConfigSpecMapNotEmpty, hasWorkerNodeConfigSpecMap) + + if hasWorkerNodeConfigSpecMap { + assert.Len( + t, + cd.nutanixWorkerNodeConfigSpecByMachineDeploymentName, tt.expectedWorkerNodeConfigSpecMapEntryCount, + ) + } + }) + } +}