From 9592578623acc84a27a62c25f3c9969a59c0cfb2 Mon Sep 17 00:00:00 2001 From: "ankush.patanwal" Date: Sun, 4 May 2025 12:53:48 +0000 Subject: [PATCH 1/3] Added unit and e2e tests --- Makefile | 10 +- README.md | 49 +++- charts/README.md | 3 +- charts/templates/deploy.yaml | 1 - charts/templates/secret.yaml | 3 +- charts/values.yaml | 4 +- cmd/cosi-driver-nutanix/cmd.go | 16 +- go.mod | 58 +++- go.sum | 151 ++++++++-- pkg/admin/user.go | 35 ++- pkg/admin/user_test.go | 245 ++++++++++++++++ pkg/admin/util.go | 63 +++-- pkg/admin/util_test.go | 189 +++++++++++++ pkg/driver/driver.go | 15 +- pkg/driver/identityserver.go | 6 +- pkg/driver/identityserver_test.go | 29 ++ pkg/driver/provisioner.go | 45 +-- pkg/driver/provisioner_test.go | 292 +++++++++++++++++++ pkg/util/s3client/policy.go | 117 ++++---- pkg/util/s3client/policy_test.go | 221 +++++++++++++++ pkg/util/s3client/s3-handlers.go | 31 +- pkg/util/s3client/s3-handlers_test.go | 324 +++++++++++++++++++++ pkg/util/transport/transport_test.go | 121 ++++++++ project/resources/secret.yaml | 8 +- project/resources/triton.yaml | 51 ++++ scripts/setup_test_env.sh | 209 ++++++++++++++ tests/e2e/create_suite_test.go | 138 +++++++++ tests/e2e/delete_suite_test.go | 249 ++++++++++++++++ tests/e2e/e2e_suite_test.go | 162 +++++++++++ tests/e2e/grant_suite_test.go | 322 +++++++++++++++++++++ tests/e2e/helpers/helpers.go | 392 ++++++++++++++++++++++++++ tests/e2e/revoke_suite_test.go | 290 +++++++++++++++++++ tests/fakes/mocks.go | 81 ++++++ 33 files changed, 3741 insertions(+), 189 deletions(-) create mode 100644 pkg/admin/user_test.go create mode 100644 pkg/admin/util_test.go create mode 100644 pkg/driver/identityserver_test.go create mode 100644 pkg/driver/provisioner_test.go create mode 100644 pkg/util/s3client/policy_test.go create mode 100644 pkg/util/s3client/s3-handlers_test.go create mode 100644 pkg/util/transport/transport_test.go create mode 100644 project/resources/triton.yaml create mode 100755 scripts/setup_test_env.sh create mode 100644 tests/e2e/create_suite_test.go create mode 100644 tests/e2e/delete_suite_test.go create mode 100644 tests/e2e/e2e_suite_test.go create mode 100644 tests/e2e/grant_suite_test.go create mode 100644 tests/e2e/helpers/helpers.go create mode 100644 tests/e2e/revoke_suite_test.go create mode 100644 tests/fakes/mocks.go diff --git a/Makefile b/Makefile index 443dbd3..5b778b6 100644 --- a/Makefile +++ b/Makefile @@ -17,9 +17,6 @@ CMDS=cosi-driver-nutanix REGISTRY_NAME=ghcr.io/nutanix-cloud-native/cosi-driver-nutanix IMAGE_TAG=latest -LOCAL_IMAGE_NAME=cosi-driver-nutanix -LOCAL_IMAGE_TAG=debug - all: build .PHONY: build-% build container-% container clean @@ -54,7 +51,6 @@ docker-push: clean: -rm -rf bin -# Creates an image of the driver in local environment -local-%: build-% - docker build -t $(LOCAL_IMAGE_NAME):$(LOCAL_IMAGE_TAG) -f package/docker/Dockerfile --label revision=$(REV) . -local: $(CMDS:%=local-%) +.PHONY: e2e-tests +e2e-tests: + ginkgo -v --tags e2e_test ./tests/... \ No newline at end of file diff --git a/README.md b/README.md index ff5b983..80c4935 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,8 @@ $ cd cosi-driver-nutanix - `ENDPOINT` : Nutanix Object Store Endpoint - `ACCESS_KEY` : Nutanix Object Store Access Key - `SECRET_KEY` : Nutanix Object Store Secret Key -- `PC_SECRET` : Prism Central Credentials in the form 'prism-ip:prism-port:username:password' +- `PC_ENDPOINT` : Prism Central endpoint' +- `PC_SECRET` : Prism Central Credentials in the form 'username:password' - `S3_INSECURE` : Controls whether certificate chain will be validated for S3 endpoint (Default: "false") - `PC_INSECURE` : Controls whether certificate chain will be validated for Prism Central (Default: "false") - `ACCOUNT_NAME` (Optional) : DisplayName identifier prefix for Nutanix Object Store (Default_Prefix: ntnx-cosi-iam-user) @@ -168,9 +169,11 @@ Update the `objectstorage-provisioner` secret that is used by the running provis ACCESS_KEY: "" # Admin IAM Secret key to be used for Nutanix Objects SECRET_KEY: "" - # PC Credentials in format :::. - # eg. "::user:password" - PC_SECRET: "" + # Prism Central endpoint, eg. "https://10.51.149.82:9440" + PC_ENDPOINT: "" + # PC Credentials in format :. + # eg. "user:password" + PC_SECRET: ":" # Controls whether certificate chain will be validated for S3 endpoint # If INSECURE is set to true, an insecure connection will be made with # the S3 endpoint (Certs will not be used) @@ -221,3 +224,41 @@ $ make REGISTRY_NAME=SampleRegistryUsername/cosi-driver-nutanix IMAGE_TAG=latest ``` Your custom image `SampleRegistry/cosi-driver-nutanix:latest` is now ready to be used. + +## Running Tests +### Unit Tests +Execute the following to run unit tests: +```sh +go test ./... +``` +To generate the coverage report and an HTML page to view the report: +```sh +go test -coverprofile=coverage.out ./... +go tool cover -html=coverage.out -o coverage.html +``` +Open the HTML file in any web vrowser to view the coverage of each file. + +### E2E Tests +Execute the following to run E2E tests: +```sh +sh scripts/setup_test_env.sh [flags] +``` +``` +Options: +-o, --oss_endpoint ENDPOINT Nutanix Object Store instance endpoint, eg. "http://10.51.142.82:80". +-i, --pc_endpoint ENDPOINT Prism Central endpoint, eg. "https://10.51.142.82:9440". +-u, --pc_user USERNAME Prism Central username. [default = admin] +-p, --pc_pass PASSWORD Prism Central password. +-a, --access_key KEY Admin IAM Access key to be used for Nutanix Objects. +-s, --secret_key KEY Admin IAM Secret key to be used for Nutanix Objects. +-n, --namespace NAMESAPCE Cluster namespace for the COSI deployment [default = cosi] +``` +You can also run the E2E tests on Triton in a local environment if a real cluster is not available. +To run on Triton, you need the image [http://uranus.corp.nutanix.com/~ankush.patanwal/objects-triton.tar.gz] and k8s cluster running locally (eg. `minikube`) then execute the script with flag `-t` or `--use_triton`. +```sh +sh scripts/setup_test_env.sh -t +``` +NOTE: You will need to load the Triton image to the local cluster. In `minikube` this can be done using: +```sh +minikube image load objects-triton:debug +``` diff --git a/charts/README.md b/charts/README.md index 79bd2f3..8a919d9 100644 --- a/charts/README.md +++ b/charts/README.md @@ -51,8 +51,7 @@ The following table lists the configurable parameters of the cosi-driver-nutanix | `secret.endpoint` | Nutanix Object Store instance endpoint | Yes | `""` | | `secret.access_key` | Admin IAM Access key to be used for Nutanix Objects | Yes | `""` | | `secret.secret_key` | Admin IAM Secret key to be used for Nutanix Objects | Yes | `""` | -| `secret.pc_ip` | PC ip | Yes | `""` | -| `secret.pc_port` | PC port | Yes | `""` | +| `secret.pc_endpoint` | PC endpoint | Yes | `""` | | `secret.pc_username` | PC username | Yes | `""` | | `secret.pc_password` | PC password | Yes | `""` | | `secret.account_name` | Account Name is a displayName identifier Prefix for Nutanix | No | `"ntnx-cosi-iam-user"` | diff --git a/charts/templates/deploy.yaml b/charts/templates/deploy.yaml index cfd3e63..b6c9699 100644 --- a/charts/templates/deploy.yaml +++ b/charts/templates/deploy.yaml @@ -8,7 +8,6 @@ metadata: name: objectstorage-provisioner namespace: {{ .Release.Namespace }} spec: - minReadySeconds: 30 progressDeadlineSeconds: 600 replicas: {{ .Values.replicas }} revisionHistoryLimit: 3 diff --git a/charts/templates/secret.yaml b/charts/templates/secret.yaml index 29b82fc..303491f 100644 --- a/charts/templates/secret.yaml +++ b/charts/templates/secret.yaml @@ -12,7 +12,8 @@ stringData: ACCESS_KEY: {{ required "access_key is required." .Values.secret.access_key | quote }} ACCOUNT_NAME: {{ .Values.secret.account_name | quote }} ENDPOINT: {{ required "endpoint is required." .Values.secret.endpoint | quote }} - PC_SECRET: "{{ required "pc_ip is required." .Values.secret.pc_ip }}:{{ required "pc_port is required." .Values.secret.pc_port }}:{{ required "pc_username is required." .Values.secret.pc_username }}:{{ required "pc_password is required." .Values.secret.pc_password }}" + PC_ENDPOINT: "{{ required "pc_endpoint is required." .Values.secret.pc_endpoint }}" + PC_SECRET: "{{ required "pc_username is required." .Values.secret.pc_username }}:{{ required "pc_password is required." .Values.secret.pc_password }}" SECRET_KEY: {{ required "secret_key is required." .Values.secret.secret_key | quote }} S3_INSECURE: {{ .Values.tls.s3.insecure | default "false" | quote }} PC_INSECURE: {{ .Values.tls.pc.insecure | default "false" | quote }} diff --git a/charts/values.yaml b/charts/values.yaml index 58e8836..4efc625 100644 --- a/charts/values.yaml +++ b/charts/values.yaml @@ -19,9 +19,9 @@ secret: access_key: "" # Admin IAM Secret key to be used for Nutanix Objects. secret_key: "" + # PC Endpoint + pc_endpoint: "" # PC Credentials. - pc_ip: "" - pc_port: "9440" pc_username: "admin" pc_password: "" # Account Name is a displayName identifier Prefix for Nutanix. diff --git a/cmd/cosi-driver-nutanix/cmd.go b/cmd/cosi-driver-nutanix/cmd.go index 8ea2dd2..56ef0f2 100644 --- a/cmd/cosi-driver-nutanix/cmd.go +++ b/cmd/cosi-driver-nutanix/cmd.go @@ -99,11 +99,17 @@ func init() { SecretKey, "Admin IAM Secret key to be used for Nutanix Objects") + stringFlag(&PCEndpoint, + "pc_endpoint", + "t", + PCEndpoint, + "Prism Central Endpoint, eg: https://10.56.192.122:9440") + stringFlag(&PCSecret, "pc_secret", "k", PCSecret, - "Prism Central Credentials in the format :::") + "Prism Central Credentials in the format :") stringFlag(&AccountName, "account_name", @@ -144,13 +150,19 @@ func init() { } func run(ctx context.Context) error { - PCEndpoint, PCUsername, PCPassword, err := ntnxIam.GetCredsFromPCSecret(PCSecret) + PCUsername, PCPassword, err := ntnxIam.GetCredsFromPCSecret(PCSecret) if err != nil { errMsg := fmt.Errorf("failed to extract PC credential information from secret: %w", err) klog.Error(errMsg) return err } + err = ntnxIam.ValidateEndpoint(PCEndpoint) + if err != nil { + klog.Error(fmt.Errorf("failed to validate PC endpoint: %w", err)) + return err + } + identityServer, bucketProvisioner, err := driver.NewDriver(ctx, provisionerName, Endpoint, diff --git a/go.mod b/go.mod index cc64dd9..e0e9838 100644 --- a/go.mod +++ b/go.mod @@ -9,8 +9,10 @@ require ( github.com/spf13/cobra v1.8.1 github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.19.0 + github.com/stretchr/testify v1.9.0 google.golang.org/grpc v1.69.4 - k8s.io/apimachinery v0.32.0 + k8s.io/api v0.32.3 + k8s.io/apimachinery v0.32.3 k8s.io/klog/v2 v2.130.1 sigs.k8s.io/container-object-storage-interface-provisioner-sidecar v0.1.1-0.20230130215648-c0cf9951ffc6 sigs.k8s.io/container-object-storage-interface-spec v0.1.1-0.20221006174327-ec782953b8ac @@ -18,32 +20,70 @@ require ( ) require ( + github.com/emicklei/go-restful/v3 v3.11.0 // indirect + github.com/fxamacker/cbor/v2 v2.7.0 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/gnostic-models v0.6.8 // indirect + github.com/google/gofuzz v1.2.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.7.7 // 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/x448/float16 v0.8.4 // indirect + golang.org/x/oauth2 v0.23.0 // indirect + golang.org/x/term v0.30.0 // indirect + golang.org/x/time v0.7.0 // indirect + gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect + k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.4.2 // indirect + sigs.k8s.io/yaml v1.4.0 // indirect +) + +require ( + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/go-logr/logr v1.4.2 // indirect + github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/golang/protobuf v1.5.4 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect - github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect + github.com/onsi/ginkgo/v2 v2.23.4 + github.com/onsi/gomega v1.37.0 github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.11.0 // indirect github.com/spf13/cast v1.6.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect - go.uber.org/atomic v1.9.0 // indirect - go.uber.org/multierr v1.9.0 // indirect - golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect - golang.org/x/net v0.34.0 // indirect - golang.org/x/sys v0.29.0 // indirect - golang.org/x/text v0.21.0 // indirect + go.uber.org/automaxprocs v1.6.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect + golang.org/x/net v0.37.0 // indirect + golang.org/x/sys v0.32.0 // indirect + golang.org/x/text v0.23.0 // indirect + golang.org/x/tools v0.31.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53 // indirect - google.golang.org/protobuf v1.35.1 // indirect + google.golang.org/protobuf v1.36.5 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/client-go v0.32.3 + sigs.k8s.io/container-object-storage-interface-api v0.1.0 + sigs.k8s.io/controller-runtime v0.20.4 // indirect sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect ) diff --git a/go.sum b/go.sum index 570b2a3..685075d 100644 --- a/go.sum +++ b/go.sum @@ -1,22 +1,47 @@ github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU= github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= +github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +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.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +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.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +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-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/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= +github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= @@ -27,6 +52,13 @@ github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9Y 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= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +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.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= @@ -35,10 +67,21 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 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/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= -github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus= +github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8= +github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y= +github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -46,8 +89,10 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= +github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= @@ -72,11 +117,16 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY= go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE= go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE= @@ -87,27 +137,64 @@ go.opentelemetry.io/otel/sdk/metric v1.31.0 h1:i9hxxLJF/9kkvfHppyLL55aW7iIJz4Jjx go.opentelemetry.io/otel/sdk/metric v1.31.0/go.mod h1:CRInTMVvNhUKgSAMbKyTMxqOBC0zgyxzW55lZzX43Y8= go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys= go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A= -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/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= -go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= -golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= -golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= -golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= -golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= -golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= -golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= +go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= +golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs= +golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= +golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ= +golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU= +golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53 h1:X58yt85/IXCx0Y3ZwN6sEIKZzQtDEYaBWrDvErdXrRE= google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI= google.golang.org/grpc v1.69.4 h1:MF5TftSMkd8GLw/m0KM6V8CMOCY6NZ1NQDPGFgbTt4A= google.golang.org/grpc v1.69.4/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4= -google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= -google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= +google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b h1:QRR6H1YWRnHb4Y/HeNFCTJLFVxaq6wH4YuVdsUOr75U= -gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= +gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= @@ -116,13 +203,29 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/apimachinery v0.32.0 h1:cFSE7N3rmEEtv4ei5X6DaJPHHX0C+upp+v5lVPiEwpg= -k8s.io/apimachinery v0.32.0/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE= +k8s.io/api v0.32.3 h1:Hw7KqxRusq+6QSplE3NYG4MBxZw1BZnq4aP4cJVINls= +k8s.io/api v0.32.3/go.mod h1:2wEDTXADtm/HA7CCMD8D8bK4yuBUptzaRhYcYEEYA3k= +k8s.io/apimachinery v0.32.3 h1:JmDuDarhDmA/Li7j3aPrwhpNBA94Nvk5zLeOge9HH1U= +k8s.io/apimachinery v0.32.3/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE= +k8s.io/client-go v0.32.3 h1:RKPVltzopkSgHS7aS98QdscAgtgah/+zmpAogooIqVU= +k8s.io/client-go v0.32.3/go.mod h1:3v0+3k4IcT9bXTc4V2rt+d2ZPPG700Xy6Oi0Gdl2PaY= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f h1:GA7//TjRY9yWGy1poLzYYJJ4JRdzg3+O6e8I+e+8T5Y= +k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f/go.mod h1:R/HEjbvWI0qdfb8viZUeVZm0X6IZnxAydC7YU42CMw4= +k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro= +k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/container-object-storage-interface-api v0.1.0 h1:8tB6JFQhbQIC1hwGQ+q4+tmSSNfjKemb7bFI6C0CK/4= +sigs.k8s.io/container-object-storage-interface-api v0.1.0/go.mod h1:YiB+i/UGkzqgODDhRG3u7jkbWkQcoUeLEJ7hwOT/2Qk= sigs.k8s.io/container-object-storage-interface-provisioner-sidecar v0.1.1-0.20230130215648-c0cf9951ffc6 h1:rVG6pl5uVyDbEqx11+cF9SNMV2FA01T3nmj0Y5thJzQ= sigs.k8s.io/container-object-storage-interface-provisioner-sidecar v0.1.1-0.20230130215648-c0cf9951ffc6/go.mod h1:U4jXJB8bpQ3a51VIPnbd6m7pshVey5m4PDAhvCKitds= sigs.k8s.io/container-object-storage-interface-spec v0.1.1-0.20221006174327-ec782953b8ac h1:M1ZBBDJVWw3gDmE+kZZmwQ6+29GbWhG9RMqx9oV0tEs= sigs.k8s.io/container-object-storage-interface-spec v0.1.1-0.20221006174327-ec782953b8ac/go.mod h1:SzF/yVSh88TgYdBOAXqhT96XjU8pCQtoeQKxzIOOmWQ= +sigs.k8s.io/controller-runtime v0.20.4 h1:X3c+Odnxz+iPTRobG4tp092+CvBU9UK0t/bRf+n0DGU= +sigs.k8s.io/controller-runtime v0.20.4/go.mod h1:xg2XB0K5ShQzAgsoujxuKN4LNXR2LfwwHsPj7Iaw+XY= sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8= sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo= +sigs.k8s.io/structured-merge-diff/v4 v4.4.2 h1:MdmvkGuXi/8io6ixD5wud3vOLwc1rj0aNqRlpuvjmwA= +sigs.k8s.io/structured-merge-diff/v4 v4.4.2/go.mod h1:N8f93tFZh9U6vpxwRArLiikrE5/2tiu1w1AGfACIGE4= +sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= +sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/pkg/admin/user.go b/pkg/admin/user.go index b967eee..44c338f 100644 --- a/pkg/admin/user.go +++ b/pkg/admin/user.go @@ -6,7 +6,7 @@ import ( "encoding/json" "errors" "fmt" - "io/ioutil" + "io" "net/http" "time" ) @@ -90,27 +90,27 @@ func (api *API) CreateUser(ctx context.Context, username, display_name string) ( // Send Request request, err := http.NewRequest("POST", url, bytes.NewBuffer(data)) if err != nil { - return result, fmt.Errorf("%w", err) + return result, fmt.Errorf("failed to create http request. %w", err) } request.SetBasicAuth(api.PCUsername, api.PCPassword) request.Header.Add("Content-Type", "application/json") resp, err := api.HTTPClient.Do(request) if err != nil { - return result, fmt.Errorf("%w", err) + return result, fmt.Errorf("failed to send http request. %w", err) } defer resp.Body.Close() - // Check respsonse status - if resp.StatusCode != 200 { - return result, fmt.Errorf("%s", resp.Status) - } - - decodedResponse, err := ioutil.ReadAll(resp.Body) + decodedResponse, err := io.ReadAll(resp.Body) if err != nil { return result, fmt.Errorf("%w", err) } + // Check respsonse status + if resp.StatusCode != 200 { + return result, fmt.Errorf("non-200 response: %d - %s", resp.StatusCode, string(decodedResponse)) + } + // Unmarshal response into Go type err = json.Unmarshal(decodedResponse, &result) if err != nil { @@ -127,7 +127,7 @@ func (api *API) CreateUser(ctx context.Context, username, display_name string) ( return result, fmt.Errorf("%s. %s. %w", unmarshalError, string(decodedResponse), err) } - return NutanixUserResp{}, fmt.Errorf("errorCode : %d, errorMessage : %s", errorResp.Users[0].Code, errorResp.Users[0].Message) + return NutanixUserResp{}, fmt.Errorf("user not created. errorCode : %d, errorMessage : %s", errorResp.Users[0].Code, errorResp.Users[0].Message) } return result, nil @@ -144,19 +144,26 @@ func (api *API) RemoveUser(ctx context.Context, uuid string) error { delete_url := api.PCEndpoint + deleteEndpoint + string(uuid) delete_request, err := http.NewRequest("DELETE", delete_url, nil) if err != nil { - return fmt.Errorf("%w", err) + return fmt.Errorf("failed to create http request. %w", err) } delete_request.SetBasicAuth(api.PCUsername, api.PCPassword) delete_resp, err := api.HTTPClient.Do(delete_request) if err != nil { - return fmt.Errorf("%w", err) + return fmt.Errorf("failed to send http request. %w", err) } defer delete_resp.Body.Close() + decodedResponse, err := io.ReadAll(delete_resp.Body) + if err != nil { + return fmt.Errorf("%w", err) + } + // Check response status - if delete_resp.StatusCode != 204 { - return fmt.Errorf("%s", delete_resp.Status) + if delete_resp.StatusCode == 404 { + return nil + } else if delete_resp.StatusCode != 204 { + return fmt.Errorf("non-204 response: %d - %s", delete_resp.StatusCode, string(decodedResponse)) } return nil } diff --git a/pkg/admin/user_test.go b/pkg/admin/user_test.go new file mode 100644 index 0000000..e3df453 --- /dev/null +++ b/pkg/admin/user_test.go @@ -0,0 +1,245 @@ +package admin_test + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "testing" + + "github.com/nutanix-core/k8s-ntnx-object-cosi/pkg/admin" + mocks "github.com/nutanix-core/k8s-ntnx-object-cosi/tests/fakes" + + "github.com/stretchr/testify/assert" +) + +var ( + ctx = context.Background() + baseApi = admin.API{ + PCEndpoint: "https://pc.example.com", + PCUsername: "admin", + PCPassword: "password", + } +) + +func TestCreateUser(t *testing.T) { + mockUsername := "testuser" + mockDisplayName := "Test User" + mockRespBody := `{ + "users": [{ + "username": "testuser", + "display_name": "Test User", + "type": "external", + "created_time": "2025-01-01T00:00:00Z", + "last_updated_time": "2025-01-01T00:00:00Z", + "tenant_id": "tenant-id", + "uuid": "user-uuid", + "buckets_access_keys": [{ + "access_key_id": "access-key", + "secret_access_key": "secret-key", + "created_time": "2025-01-01T00:00:00Z" + }] + }] + }` + + t.Run("TestCreateUser_Success", func(t *testing.T) { + mockClient := mocks.MockHTTPClient{ + DoFunc: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(mockRespBody)), + }, nil + }, + } + + api := baseApi + api.HTTPClient = mockClient + + resp, err := api.CreateUser(ctx, mockUsername, mockDisplayName) + assert.NoError(t, err) + assert.Equal(t, mockUsername, resp.Users[0].Username) + assert.Equal(t, "access-key", resp.Users[0].BucketsAccessKeys[0].AccessKeyID) + assert.NotNil(t, len(resp.Users[0].BucketsAccessKeys)) + }) + + t.Run("TestCreateUser_UserNotCreated", func(t *testing.T) { + mockClient := mocks.MockHTTPClient{ + DoFunc: func(req *http.Request) (*http.Response, error) { + body := `{ + "users": [{ + "buckets_access_keys": [] + }] + }` + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(body)), + }, nil + }, + } + + api := baseApi + api.HTTPClient = mockClient + + resp, err := api.CreateUser(ctx, mockUsername, mockDisplayName) + assert.Error(t, err) + assert.Equal(t, admin.NutanixUserResp{}, resp) + assert.Contains(t, err.Error(), "user not created") + }) + + t.Run("TestCreateUser_MissingUsername", func(t *testing.T) { + api := &admin.API{} + _, err := api.CreateUser(ctx, "", mockDisplayName) + assert.Contains(t, err.Error(), "username not set") + }) + + t.Run("TestCreateUser_CreateRequestError", func(t *testing.T) { + api := baseApi + api.PCEndpoint = "://" + + _, err := api.CreateUser(ctx, mockUsername, mockDisplayName) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to create http request") + }) + + t.Run("TestCreateUser_SendRequestError", func(t *testing.T) { + mockClient := mocks.MockHTTPClient{ + DoFunc: func(req *http.Request) (*http.Response, error) { + return nil, errors.New("failed to send http request") + }, + } + + api := baseApi + api.HTTPClient = mockClient + + _, err := api.CreateUser(ctx, mockUsername, mockDisplayName) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to send http request") + }) + + t.Run("TestCreateUser_Non200Response", func(t *testing.T) { + mockClient := mocks.MockHTTPClient{ + DoFunc: func(req *http.Request) (*http.Response, error) { + errMsg := `"error":"internal server error"` + return &http.Response{ + StatusCode: 500, + Status: "500 Internal Server Error", + Body: io.NopCloser(bytes.NewBufferString(errMsg)), + }, nil + }, + } + + api := baseApi + api.HTTPClient = mockClient + + _, err := api.CreateUser(ctx, mockUsername, mockDisplayName) + assert.Error(t, err) + assert.Contains(t, err.Error(), "non-200 response") + }) + + t.Run("TestCreateUser_UnmarshalError", func(t *testing.T) { + mockClient := mocks.MockHTTPClient{ + DoFunc: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString("{bad json")), + }, nil + }, + } + + api := baseApi + api.HTTPClient = mockClient + + _, err := api.CreateUser(ctx, mockUsername, mockDisplayName) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unmarshal") + }) + +} + +func TestRemoveUser(t *testing.T) { + ctx := context.Background() + + t.Run("TestRemoveUser_MissingUUID", func(t *testing.T) { + api := baseApi + api.HTTPClient = mocks.MockHTTPClient{} + + err := api.RemoveUser(ctx, "") + assert.Contains(t, err.Error(), "user UUID not set") + }) + + t.Run("TestRemoveUser_CreateRequestError", func(t *testing.T) { + api := baseApi + api.PCEndpoint = "://" + + err := api.RemoveUser(ctx, "some-id") + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to create http request") + }) + + t.Run("TestRemoveUser_SendRequestError", func(t *testing.T) { + api := baseApi + api.HTTPClient = mocks.MockHTTPClient{ + DoFunc: func(req *http.Request) (*http.Response, error) { + return nil, errors.New("http client error") + }, + } + err := api.RemoveUser(ctx, "some-id") + assert.Contains(t, err.Error(), "failed to send http request") + }) + + t.Run("TestRemoveUser_404Response", func(t *testing.T) { + api := baseApi + api.HTTPClient = mocks.MockHTTPClient{ + DoFunc: func(req *http.Request) (*http.Response, error) { + body := `{ + "message": "Requested user does not exist.", + "code": 404 + }` + resp := &http.Response{ + StatusCode: 404, + Status: "404 Not Found", + Body: io.NopCloser(bytes.NewBufferString(body)), + } + return resp, nil + }, + } + err := api.RemoveUser(ctx, "some-id") + assert.NoError(t, err) + }) + + t.Run("TestRemoveUser_Non204Response", func(t *testing.T) { + api := baseApi + api.HTTPClient = mocks.MockHTTPClient{ + DoFunc: func(req *http.Request) (*http.Response, error) { + body := `{ + "message": "Requested user does not exist.", + "code": 500 + }` + resp := &http.Response{ + StatusCode: 500, + Status: "500 Internal Server Error", + Body: io.NopCloser(bytes.NewBufferString(body)), + } + return resp, nil + }, + } + err := api.RemoveUser(ctx, "some-id") + assert.Contains(t, err.Error(), "non-204 response") + }) + + t.Run("TestRemoveUser_Success", func(t *testing.T) { + api := baseApi + api.HTTPClient = mocks.MockHTTPClient{ + DoFunc: func(req *http.Request) (*http.Response, error) { + resp := &http.Response{ + StatusCode: 204, + Body: io.NopCloser(bytes.NewBufferString("")), + } + return resp, nil + }, + } + err := api.RemoveUser(ctx, "some-id") + assert.NoError(t, err) + }) +} diff --git a/pkg/admin/util.go b/pkg/admin/util.go index 0857151..d3c15be 100644 --- a/pkg/admin/util.go +++ b/pkg/admin/util.go @@ -1,10 +1,11 @@ package admin import ( + "context" "errors" "fmt" - "net" "net/http" + "net/url" "strings" "time" @@ -12,12 +13,12 @@ import ( ) var ( - errNoEndpoint = errors.New("Nutanix object store instance endpoint not set") - errNoAccessKey = errors.New("Admin IAM access key for Nutanix Objects not set") - errNoSecretKey = errors.New("Admin IAM secret key for Nutanix Objects not set") - errNoPCEndpoint = errors.New("Prism Central endpoint for IAM user management not set") - errNoPCUsername = errors.New("Prism Central username for IAM user management not set") - errNoPCPassword = errors.New("Prism Central password for IAM user management not set") + ErrNoEndpoint = errors.New("Nutanix object store instance endpoint not set") + ErrNoAccessKey = errors.New("Admin IAM access key for Nutanix Objects not set") + ErrNoSecretKey = errors.New("Admin IAM secret key for Nutanix Objects not set") + ErrNoPCEndpoint = errors.New("Prism Central endpoint for IAM user management not set") + ErrNoPCUsername = errors.New("Prism Central username for IAM user management not set") + ErrNoPCPassword = errors.New("Prism Central password for IAM user management not set") ) // HTTPClient interface that conforms to that of the http package's Client. @@ -25,6 +26,13 @@ type HTTPClient interface { Do(req *http.Request) (*http.Response, error) } +type IAMiface interface { + CreateUser(ctx context.Context, username string, display_name string) (NutanixUserResp, error) + RemoveUser(ctx context.Context, uuid string) error + GetAccountName() string + GetEndpoint() string +} + // API struct for New Client type API struct { AccessKey string @@ -41,32 +49,32 @@ type API struct { func New(endpoint, accessKey, secretKey, pcEndpoint, pcUsername, pcPassword, accountName, caCert string, insecure bool, httpClient HTTPClient) (*API, error) { // validate endpoint if endpoint == "" { - return nil, errNoEndpoint + return nil, ErrNoEndpoint } // validate access key if accessKey == "" { - return nil, errNoAccessKey + return nil, ErrNoAccessKey } // validate secret key if secretKey == "" { - return nil, errNoSecretKey + return nil, ErrNoSecretKey } // validate pc endpoint if pcEndpoint == "" { - return nil, errNoPCEndpoint + return nil, ErrNoPCEndpoint } // validate pc username if pcUsername == "" { - return nil, errNoPCUsername + return nil, ErrNoPCUsername } // validate pc password if pcPassword == "" { - return nil, errNoPCPassword + return nil, ErrNoPCPassword } // set default account_name when empty @@ -101,33 +109,34 @@ func New(endpoint, accessKey, secretKey, pcEndpoint, pcUsername, pcPassword, acc }, nil } -func GetCredsFromPCSecret(key string) (string, string, string, error) { +func GetCredsFromPCSecret(key string) (string, string, error) { // Split using ":" as delimiter - creds := strings.SplitN(string(key), ":", 4) - if len(creds) != 4 { - return "", "", "", fmt.Errorf("missing information in secret value ':::'") - } - - // Validate Prism Endpoint - err := ValidateEndpoint(creds[0]) - if err != nil { - return "", "", "", err + creds := strings.SplitN(string(key), ":", 2) + if len(creds) != 2 { + return "", "", fmt.Errorf("missing information in secret value ':'") } - return "https://" + creds[0] + ":" + creds[1], creds[2], creds[3], nil + return creds[0], creds[1], nil } -// Validate endpoint is of form : +// Validate endpoint func ValidateEndpoint(endpoint string) error { if len(endpoint) == 0 { return fmt.Errorf("endpoint is not specified") } - // epList[0] should be an IP v4 address - if _, err := net.ResolveIPAddr("ip", endpoint); err != nil { + if _, err := url.ParseRequestURI(endpoint); err != nil { return fmt.Errorf("error while resolving endpoint %s, err: %s", endpoint, err) } return nil } + +func (api *API) GetAccountName() string { + return api.AccountName +} + +func (api *API) GetEndpoint() string { + return api.Endpoint +} diff --git a/pkg/admin/util_test.go b/pkg/admin/util_test.go new file mode 100644 index 0000000..93b2a10 --- /dev/null +++ b/pkg/admin/util_test.go @@ -0,0 +1,189 @@ +package admin_test + +import ( + "net/http" + "testing" + + "github.com/nutanix-core/k8s-ntnx-object-cosi/pkg/admin" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const validPEMCert = `-----BEGIN CERTIFICATE----- +MIIDVDCCAjygAwIBAgIRALLmZX4DorTkHZzNVhA+RYUwDQYJKoZIhvcNAQELBQAw +FzEVMBMGA1UEChMMTnV0YW5peCBJbmMuMCAXDTI1MDEyNzA4MDU0NVoYDzIyMDQw +NzAzMDgwNTQ1WjAXMRUwEwYDVQQKEwxOdXRhbml4IEluYy4wggEiMA0GCSqGSIb3 +DQEBAQUAA4IBDwAwggEKAoIBAQDXvGBSfAByt1VCvAuaDThq7H+JlK8He8zcjqD7 +DGe6Dc1jq9n7+eN6X+cTT85dKPhajjPp9Nc4g1HT0jfWHD4QHhgkS12Ny0Wrmqqr +qx8Fcuzhaz89BFyaI3tOCBx75yZe9zaNSOBoKJqreu2mAjfI8LM7jFl/cON68Kd9 +/b6g89+2FME0gFq2p3mabGXGVerFAK0g7TuffhWKyKL/B9equZ2M7CmhAO7wa0ko +CnQJY3XvNk4OUeb7NXBdpswpWD789rXSsSMbxaS9ZmDrqvB7A1IlWjwEUVzxYnPw +21miqfZ5qb2p3gZtSTbDOqmg0l9yfvwIroaGKMLrRCK0Q+IjAgMBAAGjgZgwgZUw +DgYDVR0PAQH/BAQDAgKkMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFPE9h85q +h53rKbrBRd+VnUGuAxpKMFMGA1UdEQRMMEqCInJpbXVydS5wcmlzbS1jZW50cmFs +LmNsdXN0ZXIubG9jYWyCJCoucmltdXJ1LnByaXNtLWNlbnRyYWwuY2x1c3Rlci5s +b2NhbDANBgkqhkiG9w0BAQsFAAOCAQEAd3wN98xaTJqBGE2j0qoSQhqMNb5NmBDa +Cp/Pt0mwlAJmv2petz3ON5edt3/yC81vEvWfT+4GpM/6jAHcY9rZ+XQA+ZkSnjsG +itALgSLq77vDYRTHAXfsWPH2DY140IS6OqqTtLPLukHzux5uR2LH1uggU5sARs5l +EBi1znwsnSxrKfqPOurt4oSgW7FougqiaOiK+Vkm+1FtybVlMXH1w5TkePFK/x7B +OkiKpPoALmPy1Y2BxvbpxQYLjZEFMKwIo7G20pl9opFntCBs6GcY7QNesVYKawV1 +zQEsbJYBuhj1XgjzRx+6al2Fjf2NFN3I2aCKQZ9oMFsg0R/M0biBjA== +-----END CERTIFICATE----- +` + +func TestNew(t *testing.T) { + // Valid parameters for testing. + validEndpoint := "https://objectore.example.com" + validAccessKey := "dummyAccessKey" + validSecretKey := "dummySecretKey" + validPCEndpoint := "https://prism.example.com" + validPCUsername := "admin" + validPCPassword := "password" + validAccountName := "custom-account" + + t.Run("TestNew_DefaultAccount", func(t *testing.T) { + api, err := admin.New(validEndpoint, validAccessKey, validSecretKey, validPCEndpoint, validPCUsername, validPCPassword, "", validPEMCert, false, nil) + + _, ok := api.HTTPClient.(*http.Client) + require.True(t, ok) + require.NotNil(t, api) + require.NoError(t, err) + assert.Equal(t, validEndpoint, api.Endpoint) + assert.Equal(t, validAccessKey, api.AccessKey) + assert.Equal(t, validSecretKey, api.SecretKey) + assert.Equal(t, validPCEndpoint, api.PCEndpoint) + assert.Equal(t, validPCUsername, api.PCUsername) + assert.Equal(t, validPCPassword, api.PCPassword) + assert.Equal(t, "ntnx-cosi-iam-user", api.AccountName) + }) + + t.Run("TestNew_CustomAccount", func(t *testing.T) { + api, err := admin.New(validEndpoint, validAccessKey, validSecretKey, validPCEndpoint, validPCUsername, validPCPassword, validAccountName, validPEMCert, false, nil) + + require.NoError(t, err) + require.NotNil(t, api) + assert.Equal(t, validAccountName, api.AccountName) + }) + + t.Run("TestNew_EmptyEndpoint", func(t *testing.T) { + api, err := admin.New("", validAccessKey, validSecretKey, validPCEndpoint, validPCUsername, validPCPassword, validAccountName, validPEMCert, false, nil) + require.Error(t, err) + assert.Nil(t, api) + assert.Equal(t, admin.ErrNoEndpoint, err) + }) + + t.Run("TestNew_EmptyAccessKey", func(t *testing.T) { + api, err := admin.New(validEndpoint, "", validSecretKey, validPCEndpoint, validPCUsername, validPCPassword, validAccountName, validPEMCert, false, nil) + require.Error(t, err) + assert.Nil(t, api) + assert.Equal(t, admin.ErrNoAccessKey, err) + }) + + t.Run("TestNew_EmptySecretKey", func(t *testing.T) { + api, err := admin.New(validEndpoint, validAccessKey, "", validPCEndpoint, validPCUsername, validPCPassword, validAccountName, validPEMCert, false, nil) + require.Error(t, err) + assert.Nil(t, api) + assert.Equal(t, admin.ErrNoSecretKey, err) + }) + + t.Run("TestNew_EmptyPCEndpoint", func(t *testing.T) { + api, err := admin.New(validEndpoint, validAccessKey, validSecretKey, "", validPCUsername, validPCPassword, validAccountName, validPEMCert, false, nil) + require.Error(t, err) + assert.Nil(t, api) + assert.Equal(t, admin.ErrNoPCEndpoint, err) + }) + + t.Run("TestNew_EmptyPCUsername", func(t *testing.T) { + api, err := admin.New(validEndpoint, validAccessKey, validSecretKey, validPCEndpoint, "", validPCPassword, validAccountName, validPEMCert, false, nil) + require.Error(t, err) + assert.Nil(t, api) + assert.Equal(t, admin.ErrNoPCUsername, err) + }) + + t.Run("TestNew_EmptyPCPassword", func(t *testing.T) { + api, err := admin.New(validEndpoint, validAccessKey, validSecretKey, validPCEndpoint, validPCUsername, "", validAccountName, validPEMCert, false, nil) + require.Error(t, err) + assert.Nil(t, api) + assert.Equal(t, admin.ErrNoPCPassword, err) + }) + + t.Run("TestNew_BadCACert", func(t *testing.T) { + invalidCACert := "invalid-cert" + api, err := admin.New(validEndpoint, validAccessKey, validSecretKey, validPCEndpoint, validPCUsername, validPCPassword, validAccountName, invalidCACert, false, nil) + require.Error(t, err) + assert.Nil(t, api) + assert.Contains(t, err.Error(), "failed to decode CA cert") + }) +} + +func TestGetCredsFromPCSecret(t *testing.T) { + t.Run("TestGetCredsFromPCSecret_Valid", func(t *testing.T) { + // Using localhost as a resolvable IP + key := "admin:password" + username, password, err := admin.GetCredsFromPCSecret(key) + require.NoError(t, err) + assert.Equal(t, "admin", username) + assert.Equal(t, "password", password) + }) + + t.Run("TestGetCredsFromPCSecret_MissingFields", func(t *testing.T) { + key := "admin" + username, password, err := admin.GetCredsFromPCSecret(key) + require.Error(t, err) + assert.Contains(t, err.Error(), "missing information in secret value") + assert.Empty(t, username) + assert.Empty(t, password) + }) +} + +func TestValidateEndpoint(t *testing.T) { + t.Run("TestValidateEndpoint_ValidEndpoint", func(t *testing.T) { + err := admin.ValidateEndpoint("https://127.0.0.1") + assert.NoError(t, err) + }) + + t.Run("TestValidateEndpoint_EmptyString", func(t *testing.T) { + err := admin.ValidateEndpoint("") + require.Error(t, err) + assert.Equal(t, "endpoint is not specified", err.Error()) + }) + + t.Run("TestValidateEndpoint_InvalidEndpoint", func(t *testing.T) { + err := admin.ValidateEndpoint("https//thisisaninvalid.endpoint") + require.Error(t, err) + assert.Contains(t, err.Error(), "error while resolving endpoint") + }) +} + +func TestGetAccountName(t *testing.T) { + t.Run("TestGetAccountName_Success", func(t *testing.T) { + api := admin.API{ + AccountName: "test", + } + accountName := api.AccountName + assert.Equal(t, "test", accountName) + }) + + t.Run("TestGetAccountName_MissingAccountName", func(t *testing.T) { + api := admin.API{} + accountName := api.AccountName + assert.Empty(t, accountName) + }) +} + +func TestGetEndpoint(t *testing.T) { + t.Run("TestGetEndpoint_Success", func(t *testing.T) { + api := admin.API{ + Endpoint: "test", + } + endpoint := api.GetEndpoint() + assert.Equal(t, "test", endpoint) + }) + + t.Run("TestGetEndpoint_MissingEndpoint", func(t *testing.T) { + api := admin.API{} + endpoint := api.GetEndpoint() + assert.Empty(t, endpoint) + }) +} diff --git a/pkg/driver/driver.go b/pkg/driver/driver.go index 08bf72b..6d1c43d 100644 --- a/pkg/driver/driver.go +++ b/pkg/driver/driver.go @@ -31,19 +31,22 @@ func NewDriver(ctx context.Context, provisioner, ntnxEndpoint, accessKey, secret s3Client, err := s3client.NewS3Agent(accessKey, secretKey, ntnxEndpoint, s3CaCert, s3Insecure, true) if err != nil { errMsg := fmt.Errorf("failed to create S3 client: %w", err) - klog.Fatalln(errMsg) + klog.Errorln(errMsg) + return nil, nil, err } ntnxIamClient, err := ntnxIam.New(ntnxEndpoint, accessKey, secretKey, pcEndpoint, pcUsername, pcPassword, accountName, pcCaCert, pcInsecure, nil) if err != nil { errMsg := fmt.Errorf("failed to create IAM client: %w", err) - klog.Fatalln(errMsg) + klog.Errorln(errMsg) + return nil, nil, err + } return &IdentityServer{ - provisioner: provisioner, + Provisioner: provisioner, }, &ProvisionerServer{ - provisioner: provisioner, - s3Client: s3Client, - ntnxIamClient: ntnxIamClient, + Provisioner: provisioner, + S3Client: s3Client, + NtnxIamClient: ntnxIamClient, }, nil } diff --git a/pkg/driver/identityserver.go b/pkg/driver/identityserver.go index 2096ddf..142e477 100644 --- a/pkg/driver/identityserver.go +++ b/pkg/driver/identityserver.go @@ -28,18 +28,18 @@ import ( ) type IdentityServer struct { - provisioner string + Provisioner string } func (id *IdentityServer) DriverGetInfo(ctx context.Context, req *cosi.DriverGetInfoRequest) (*cosi.DriverGetInfoResponse, error) { - if id.provisioner == "" { + if id.Provisioner == "" { klog.ErrorS(fmt.Errorf("provisioner name cannot be empty"), "Invalid argument") return nil, status.Error(codes.InvalidArgument, "Provisioner name is empty") } return &cosi.DriverGetInfoResponse{ - Name: id.provisioner, + Name: id.Provisioner, }, nil } diff --git a/pkg/driver/identityserver_test.go b/pkg/driver/identityserver_test.go new file mode 100644 index 0000000..8fa2d4f --- /dev/null +++ b/pkg/driver/identityserver_test.go @@ -0,0 +1,29 @@ +package driver_test + +import ( + "context" + "testing" + + "github.com/nutanix-core/k8s-ntnx-object-cosi/pkg/driver" + "github.com/stretchr/testify/assert" + cosi "sigs.k8s.io/container-object-storage-interface-spec" +) + +func TestDriverGetInfo(t *testing.T) { + t.Run("TestDriverGetInfo_ValidServer", func(t *testing.T) { + srv := &driver.IdentityServer{ + Provisioner: "dummy", + } + + res, err := srv.DriverGetInfo(context.Background(), &cosi.DriverGetInfoRequest{}) + assert.NoError(t, err) + assert.Equal(t, "dummy", res.Name) + }) + + t.Run("TestDriverGetInfo_EmptyProvisionerName", func(t *testing.T) { + srv := &driver.IdentityServer{} + res, err := srv.DriverGetInfo(context.Background(), &cosi.DriverGetInfoRequest{}) + assert.Error(t, err) + assert.Nil(t, res) + }) +} diff --git a/pkg/driver/provisioner.go b/pkg/driver/provisioner.go index 628e036..14525cd 100644 --- a/pkg/driver/provisioner.go +++ b/pkg/driver/provisioner.go @@ -31,18 +31,19 @@ import ( // 1.) for ntnxIamClientOps : mainly for user related operations // 2.) for S3 operations : mainly for bucket related operations type ProvisionerServer struct { - provisioner string - s3Client *s3cli.S3Agent - ntnxIamClient *ntnxIam.API + Provisioner string + S3Client s3cli.S3iface + NtnxIamClient ntnxIam.IAMiface } // ProvisionerCreateBucket is a method for creating buckets // It is expected to create the same bucket given a bucketName and protocol // If the bucket already exists, then it MUST return codes.AlreadyExists // Return values -// nil - Bucket successfully created -// codes.AlreadyExists - Bucket already exists. No more retries -// non-nil err - Internal error [requeue'd with exponential backoff] +// +// nil - Bucket successfully created +// codes.AlreadyExists - Bucket already exists. No more retries +// non-nil err - Internal error [requeue'd with exponential backoff] func (s *ProvisionerServer) DriverCreateBucket(ctx context.Context, req *cosi.DriverCreateBucketRequest) (*cosi.DriverCreateBucketResponse, error) { klog.InfoS("Using Nutanix Object store to create Backend Bucket") @@ -53,7 +54,7 @@ func (s *ProvisionerServer) DriverCreateBucket(ctx context.Context, bucketName := req.GetName() klog.V(3).InfoS("Creating Bucket", "name", bucketName) - err := s.s3Client.CreateBucket(bucketName) + err := s.S3Client.CreateBucket(bucketName) if err != nil { // Check to see if the bucket already exists by above API klog.ErrorS(err, "failed to create bucket", "bucketName", bucketName) @@ -70,7 +71,7 @@ func (s *ProvisionerServer) DriverCreateBucket(ctx context.Context, func (s *ProvisionerServer) DriverDeleteBucket(ctx context.Context, req *cosi.DriverDeleteBucketRequest) (*cosi.DriverDeleteBucketResponse, error) { klog.InfoS("Deleting bucket", "id", req.GetBucketId()) - if _, err := s.s3Client.DeleteBucket(req.GetBucketId()); err != nil { + if _, err := s.S3Client.DeleteBucket(req.GetBucketId()); err != nil { klog.ErrorS(err, "failed to delete bucket %q", req.GetBucketId()) return nil, status.Error(codes.Internal, "failed to delete bucket") } @@ -86,27 +87,27 @@ func (s *ProvisionerServer) DriverGrantBucketAccess(ctx context.Context, // stored in req which is of the form- "ba-" // with the suffix "@nutanix.com" userName := req.GetName() + "@nutanix.com" - displayName := s.ntnxIamClient.AccountName + "_" + req.GetName() + displayName := s.NtnxIamClient.GetAccountName() + "_" + req.GetName() bucketName := req.GetBucketId() klog.InfoS("Granting user accessPolicy to bucket", "userName", userName, "displayName", displayName, "bucketName", bucketName) - // Format : {type: "external", email: @nutanix.com, displayname: _ (optional)} - user, err := s.ntnxIamClient.CreateUser(ctx, userName, displayName) - if err != nil { - klog.ErrorS(err, "failed to create an IAM user for Nutanix Objects") - return nil, err - } - // Fetch Bucket Policy - policy, err := s.s3Client.GetBucketPolicy(bucketName) + policy, err := s.S3Client.GetBucketPolicy(bucketName) if err != nil { if aerr, ok := err.(awserr.Error); ok && aerr.Code() != "NoSuchBucketPolicy" { return nil, status.Error(codes.Internal, "fetching policy failed") } } + // Format : {type: "external", email: @nutanix.com, displayname: _ (optional)} + user, err := s.NtnxIamClient.CreateUser(ctx, userName, displayName) + if err != nil { + klog.ErrorS(err, "failed to create an IAM user for Nutanix Objects") + return nil, err + } + // Share bucket with the newly created IAM user statement := s3cli.NewPolicyStatement(). WithSID(userName). @@ -120,15 +121,17 @@ func (s *ProvisionerServer) DriverGrantBucketAccess(ctx context.Context, } else { policy = policy.ModifyBucketPolicy(*statement) } - _, err = s.s3Client.PutBucketPolicy(bucketName, *policy) + _, err = s.S3Client.PutBucketPolicy(bucketName, *policy) if err != nil { klog.ErrorS(err, "failed to set policy") return nil, status.Error(codes.Internal, "failed to set policy") } + klog.InfoS("Successfully granted access to user.", "userName", userName, "displayName", displayName, "bucketName", bucketName) + return &cosi.DriverGrantBucketAccessResponse{ AccountId: user.Users[0].UUID, - Credentials: fetchUserCredentials(user, s.ntnxIamClient.Endpoint), + Credentials: fetchUserCredentials(user, s.NtnxIamClient.GetEndpoint()), }, nil } @@ -137,10 +140,12 @@ func (s *ProvisionerServer) DriverRevokeBucketAccess(ctx context.Context, klog.InfoS("Deleting user", "id", req.GetAccountId()) - err := s.ntnxIamClient.RemoveUser(ctx, req.GetAccountId()) + err := s.NtnxIamClient.RemoveUser(ctx, req.GetAccountId()) if err != nil { klog.ErrorS(err, "failed to delete user") } + + klog.InfoS("Successfully revoked access of user", "userName", req.GetAccountId(), "bucketName", req.GetBucketId()) return &cosi.DriverRevokeBucketAccessResponse{}, nil } diff --git a/pkg/driver/provisioner_test.go b/pkg/driver/provisioner_test.go new file mode 100644 index 0000000..58ee625 --- /dev/null +++ b/pkg/driver/provisioner_test.go @@ -0,0 +1,292 @@ +package driver_test + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "testing" + + "github.com/nutanix-core/k8s-ntnx-object-cosi/pkg/admin" + "github.com/nutanix-core/k8s-ntnx-object-cosi/pkg/driver" + s3cli "github.com/nutanix-core/k8s-ntnx-object-cosi/pkg/util/s3client" + mocks "github.com/nutanix-core/k8s-ntnx-object-cosi/tests/fakes" + + "github.com/aws/aws-sdk-go/aws/awserr" + "github.com/aws/aws-sdk-go/service/s3" + + "github.com/stretchr/testify/assert" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + cosi "sigs.k8s.io/container-object-storage-interface-spec" +) + +func TestDriverCreateBucket(t *testing.T) { + t.Run("TestDriverCreateBucket_Success", func(t *testing.T) { + mockS3 := mocks.MockProvisionerS3{ + CreateBucketFunc: func(name string) error { + assert.Equal(t, "test-bucket", name) + return nil + }, + } + server := &driver.ProvisionerServer{S3Client: mockS3} + req := &cosi.DriverCreateBucketRequest{ + Name: "test-bucket", + Parameters: map[string]string{}, + } + resp, err := server.DriverCreateBucket(context.Background(), req) + assert.NoError(t, err) + assert.NotNil(t, resp) + assert.Equal(t, "test-bucket", resp.BucketId) + }) + + t.Run("TestDriverCreateBucket_CreateBucketError", func(t *testing.T) { + mockS3 := mocks.MockProvisionerS3{ + CreateBucketFunc: func(name string) error { + return errors.New("s3 error") + }, + } + server := &driver.ProvisionerServer{S3Client: mockS3} + req := &cosi.DriverCreateBucketRequest{ + Name: "fail-bucket", + Parameters: map[string]string{}, + } + resp, err := server.DriverCreateBucket(context.Background(), req) + assert.Nil(t, resp) + assert.Error(t, err) + st, ok := status.FromError(err) + assert.True(t, ok) + assert.Equal(t, codes.Internal, st.Code()) + assert.Contains(t, st.Message(), "failed to create bucket") + }) +} + +func TestDriverDeleteBucket(t *testing.T) { + t.Run("TestDriverDeleteBucket_Success", func(t *testing.T) { + mockS3 := mocks.MockProvisionerS3{ + DeleteBucketFunc: func(name string) (bool, error) { + assert.Equal(t, "test-bucket", name) + return true, nil + }, + } + server := &driver.ProvisionerServer{S3Client: mockS3} + req := &cosi.DriverDeleteBucketRequest{ + BucketId: "test-bucket", + } + resp, err := server.DriverDeleteBucket(context.Background(), req) + assert.NoError(t, err) + assert.NotNil(t, resp) + }) + + t.Run("TestDriverDeleteBucket_DeleteBucketError", func(t *testing.T) { + mockS3 := &mocks.MockProvisionerS3{ + DeleteBucketFunc: func(name string) (bool, error) { + return false, errors.New("backend delete error") + }, + } + server := &driver.ProvisionerServer{S3Client: mockS3} + + req := &cosi.DriverDeleteBucketRequest{BucketId: "bucket-fail"} + resp, err := server.DriverDeleteBucket(context.Background(), req) + + assert.Nil(t, resp) + assert.Error(t, err) + assert.Equal(t, codes.Internal, status.Code(err)) + }) +} + +func TestDriverGrantBucketAccess(t *testing.T) { + mockRespBody := `{ + "users": [{ + "username": "testuser", + "display_name": "Test User", + "type": "external", + "created_time": "2025-01-01T00:00:00Z", + "last_updated_time": "2025-01-01T00:00:00Z", + "tenant_id": "tenant-id", + "uuid": "test-uuid", + "buckets_access_keys": [{ + "access_key_id": "test-key", + "secret_access_key": "test-key", + "created_time": "2025-01-01T00:00:00Z" + }] + }] + }` + mockIAM := mocks.MockIAM{ + CreateUserFunc: func(ctx context.Context, username, displayName string) (admin.NutanixUserResp, error) { + resp := admin.NutanixUserResp{} + _ = json.Unmarshal([]byte(mockRespBody), &resp) + return resp, nil + }, + GetAccountNameFunc: func() string { return "cosi" }, + GetEndpointFunc: func() string { return "https://pc-endpoint" }, + } + + t.Run("TestDriverGrantBucketAccess_NewPolicySuccess", func(t *testing.T) { + mockS3 := mocks.MockProvisionerS3{ + GetBucketPolicyFunc: func(bucket string) (*s3cli.BucketPolicy, error) { + return nil, awserr.New("NoSuchBucketPolicy", "no existing policy", nil) + }, + PutBucketPolicyFunc: func(bucket string, policy s3cli.BucketPolicy) (*s3.PutBucketPolicyOutput, error) { + return &s3.PutBucketPolicyOutput{}, nil + }, + } + + server := &driver.ProvisionerServer{ + Provisioner: "test", + S3Client: mockS3, + NtnxIamClient: mockIAM, + } + + req := &cosi.DriverGrantBucketAccessRequest{ + Name: "ba-test", + BucketId: "bucket-test", + AuthenticationType: cosi.AuthenticationType_Key, + } + + resp, err := server.DriverGrantBucketAccess(context.Background(), req) + assert.NoError(t, err) + assert.Equal(t, "test-uuid", resp.AccountId) + assert.Equal(t, "test-key", resp.Credentials["s3"].Secrets["accessKeyID"]) + assert.Equal(t, "test-key", resp.Credentials["s3"].Secrets["accessSecretKey"]) + }) + + t.Run("TestDriverGrantBucketAccess_ModifyPolicySuccess", func(t *testing.T) { + existingPolicy := s3cli.NewBucketPolicy(*s3cli.NewPolicyStatement(). + WithSID("other-user"). + ForPrincipals("other-user"). + ForResources("bucket-test"). + ForSubResources("bucket-test"). + Allows(). + Actions("s3:GetObject")) + + mockS3 := mocks.MockProvisionerS3{ + GetBucketPolicyFunc: func(bucket string) (*s3cli.BucketPolicy, error) { + return existingPolicy, nil + }, + PutBucketPolicyFunc: func(bucket string, policy s3cli.BucketPolicy) (*s3.PutBucketPolicyOutput, error) { + return &s3.PutBucketPolicyOutput{}, nil + }, + } + + server := &driver.ProvisionerServer{ + Provisioner: "test", + S3Client: mockS3, + NtnxIamClient: mockIAM, + } + + req := &cosi.DriverGrantBucketAccessRequest{ + Name: "ba-test2", + BucketId: "bucket-test", + } + + resp, err := server.DriverGrantBucketAccess(context.Background(), req) + assert.NoError(t, err) + assert.Equal(t, "test-uuid", resp.AccountId) + }) + + t.Run("TestDriverGrantBucketAccess_UserCreateFail", func(t *testing.T) { + mockIAMFail := mockIAM + mockIAMFail.CreateUserFunc = func(ctx context.Context, username, displayName string) (admin.NutanixUserResp, error) { + return admin.NutanixUserResp{}, fmt.Errorf("iam error") + } + + server := &driver.ProvisionerServer{ + S3Client: mocks.MockProvisionerS3{ + GetBucketPolicyFunc: func(bucket string) (*s3cli.BucketPolicy, error) { + return nil, nil + }, + }, + NtnxIamClient: mockIAMFail, + } + + req := &cosi.DriverGrantBucketAccessRequest{ + Name: "ba-test", + BucketId: "bucket-test", + } + + _, err := server.DriverGrantBucketAccess(context.Background(), req) + assert.Error(t, err) + assert.Contains(t, err.Error(), "iam error") + }) + + t.Run("TestDriverGrantBucketAccess_GetPolicyError", func(t *testing.T) { + mockS3 := mocks.MockProvisionerS3{ + GetBucketPolicyFunc: func(bucket string) (*s3cli.BucketPolicy, error) { + return nil, awserr.New("UnknownError", "some s3 error", nil) + }, + } + + server := &driver.ProvisionerServer{ + S3Client: mockS3, + NtnxIamClient: mockIAM, + } + + req := &cosi.DriverGrantBucketAccessRequest{ + Name: "ba-test", + BucketId: "bucket-test", + } + + _, err := server.DriverGrantBucketAccess(context.Background(), req) + assert.Error(t, err) + assert.Contains(t, err.Error(), "fetching policy failed") + }) + + t.Run("TestDriverGrantBucketAccess_PutPolicyError", func(t *testing.T) { + mockS3 := mocks.MockProvisionerS3{ + GetBucketPolicyFunc: func(bucket string) (*s3cli.BucketPolicy, error) { + return nil, awserr.New("NoSuchBucketPolicy", "no policy", nil) + }, + PutBucketPolicyFunc: func(bucket string, policy s3cli.BucketPolicy) (*s3.PutBucketPolicyOutput, error) { + return nil, fmt.Errorf("put policy failed") + }, + } + + server := &driver.ProvisionerServer{ + S3Client: mockS3, + NtnxIamClient: mockIAM, + } + + req := &cosi.DriverGrantBucketAccessRequest{ + Name: "ba-test", + BucketId: "bucket-test", + } + + _, err := server.DriverGrantBucketAccess(context.Background(), req) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to set policy") + }) +} + +func TestDriverRevokeBucketAccess(t *testing.T) { + t.Run("TestDriverRevokeBucketAccess_Success", func(t *testing.T) { + mockIAM := mocks.MockIAM{ + RemoveUserFunc: func(ctx context.Context, uuid string) error { + assert.Equal(t, "user-uuid", uuid) + return nil + }, + } + server := &driver.ProvisionerServer{ + NtnxIamClient: mockIAM, + } + req := &cosi.DriverRevokeBucketAccessRequest{AccountId: "user-uuid"} + resp, err := server.DriverRevokeBucketAccess(context.Background(), req) + assert.NoError(t, err) + assert.NotNil(t, resp) + }) + + t.Run("TestDriverRevokeBucketAccess_RemoveUserError", func(t *testing.T) { + mockIAM := mocks.MockIAM{ + RemoveUserFunc: func(ctx context.Context, uuid string) error { + return errors.New("delete user failed") + }, + } + server := &driver.ProvisionerServer{ + NtnxIamClient: mockIAM, + } + req := &cosi.DriverRevokeBucketAccessRequest{AccountId: "user-uuid"} + resp, err := server.DriverRevokeBucketAccess(context.Background(), req) + assert.NoError(t, err) + assert.NotNil(t, resp) // still returns response even on error + }) +} diff --git a/pkg/util/s3client/policy.go b/pkg/util/s3client/policy.go index 144a82c..f3cba1b 100644 --- a/pkg/util/s3client/policy.go +++ b/pkg/util/s3client/policy.go @@ -22,61 +22,61 @@ import ( "k8s.io/apimachinery/pkg/util/json" ) -type action string +type Action string const ( - All action = "s3:*" - AbortMultipartUpload action = "s3:AbortMultipartUpload" - CreateBucket action = "s3:CreateBucket" - DeleteBucketPolicy action = "s3:DeleteBucketPolicy" - DeleteBucket action = "s3:DeleteBucket" - DeleteBucketWebsite action = "s3:DeleteBucketWebsite" - DeleteObject action = "s3:DeleteObject" - DeleteObjectVersion action = "s3:DeleteObjectVersion" - DeleteReplicationConfiguration action = "s3:DeleteReplicationConfiguration" - GetAccelerateConfiguration action = "s3:GetAccelerateConfiguration" - GetBucketAcl action = "s3:GetBucketAcl" - GetBucketCORS action = "s3:GetBucketCORS" - GetBucketLocation action = "s3:GetBucketLocation" - GetBucketLogging action = "s3:GetBucketLogging" - GetBucketNotification action = "s3:GetBucketNotification" - GetBucketPolicy action = "s3:GetBucketPolicy" - GetBucketRequestPayment action = "s3:GetBucketRequestPayment" - GetBucketTagging action = "s3:GetBucketTagging" - GetBucketVersioning action = "s3:GetBucketVersioning" - GetBucketWebsite action = "s3:GetBucketWebsite" - GetLifecycleConfiguration action = "s3:GetLifecycleConfiguration" - GetObjectAcl action = "s3:GetObjectAcl" - GetObject action = "s3:GetObject" - GetObjectTorrent action = "s3:GetObjectTorrent" - GetObjectVersionAcl action = "s3:GetObjectVersionAcl" - GetObjectVersion action = "s3:GetObjectVersion" - GetObjectVersionTorrent action = "s3:GetObjectVersionTorrent" - GetReplicationConfiguration action = "s3:GetReplicationConfiguration" - ListAllMyBuckets action = "s3:ListAllMyBuckets" - ListBucketMultipartUploads action = "s3:ListBucketMultipartUploads" - ListBucket action = "s3:ListBucket" - ListBucketVersions action = "s3:ListBucketVersions" - ListMultipartUploadParts action = "s3:ListMultipartUploadParts" - PutAccelerateConfiguration action = "s3:PutAccelerateConfiguration" - PutBucketAcl action = "s3:PutBucketAcl" - PutBucketCORS action = "s3:PutBucketCORS" - PutBucketLogging action = "s3:PutBucketLogging" - PutBucketNotification action = "s3:PutBucketNotification" - PutBucketPolicy action = "s3:PutBucketPolicy" - PutBucketRequestPayment action = "s3:PutBucketRequestPayment" - PutBucketTagging action = "s3:PutBucketTagging" - PutBucketVersioning action = "s3:PutBucketVersioning" - PutBucketWebsite action = "s3:PutBucketWebsite" - PutLifecycleConfiguration action = "s3:PutLifecycleConfiguration" - PutObjectAcl action = "s3:PutObjectAcl" - PutObject action = "s3:PutObject" - PutObjectVersionAcl action = "s3:PutObjectVersionAcl" - PutReplicationConfiguration action = "s3:PutReplicationConfiguration" - RestoreObject action = "s3:RestoreObject" + All Action = "s3:*" + AbortMultipartUpload Action = "s3:AbortMultipartUpload" + CreateBucket Action = "s3:CreateBucket" + DeleteBucketPolicy Action = "s3:DeleteBucketPolicy" + DeleteBucket Action = "s3:DeleteBucket" + DeleteBucketWebsite Action = "s3:DeleteBucketWebsite" + DeleteObject Action = "s3:DeleteObject" + DeleteObjectVersion Action = "s3:DeleteObjectVersion" + DeleteReplicationConfiguration Action = "s3:DeleteReplicationConfiguration" + GetAccelerateConfiguration Action = "s3:GetAccelerateConfiguration" + GetBucketAcl Action = "s3:GetBucketAcl" + GetBucketCORS Action = "s3:GetBucketCORS" + GetBucketLocation Action = "s3:GetBucketLocation" + GetBucketLogging Action = "s3:GetBucketLogging" + GetBucketNotification Action = "s3:GetBucketNotification" + GetBucketPolicy Action = "s3:GetBucketPolicy" + GetBucketRequestPayment Action = "s3:GetBucketRequestPayment" + GetBucketTagging Action = "s3:GetBucketTagging" + GetBucketVersioning Action = "s3:GetBucketVersioning" + GetBucketWebsite Action = "s3:GetBucketWebsite" + GetLifecycleConfiguration Action = "s3:GetLifecycleConfiguration" + GetObjectAcl Action = "s3:GetObjectAcl" + GetObject Action = "s3:GetObject" + GetObjectTorrent Action = "s3:GetObjectTorrent" + GetObjectVersionAcl Action = "s3:GetObjectVersionAcl" + GetObjectVersion Action = "s3:GetObjectVersion" + GetObjectVersionTorrent Action = "s3:GetObjectVersionTorrent" + GetReplicationConfiguration Action = "s3:GetReplicationConfiguration" + ListAllMyBuckets Action = "s3:ListAllMyBuckets" + ListBucketMultipartUploads Action = "s3:ListBucketMultipartUploads" + ListBucket Action = "s3:ListBucket" + ListBucketVersions Action = "s3:ListBucketVersions" + ListMultipartUploadParts Action = "s3:ListMultipartUploadParts" + PutAccelerateConfiguration Action = "s3:PutAccelerateConfiguration" + PutBucketAcl Action = "s3:PutBucketAcl" + PutBucketCORS Action = "s3:PutBucketCORS" + PutBucketLogging Action = "s3:PutBucketLogging" + PutBucketNotification Action = "s3:PutBucketNotification" + PutBucketPolicy Action = "s3:PutBucketPolicy" + PutBucketRequestPayment Action = "s3:PutBucketRequestPayment" + PutBucketTagging Action = "s3:PutBucketTagging" + PutBucketVersioning Action = "s3:PutBucketVersioning" + PutBucketWebsite Action = "s3:PutBucketWebsite" + PutLifecycleConfiguration Action = "s3:PutLifecycleConfiguration" + PutObjectAcl Action = "s3:PutObjectAcl" + PutObject Action = "s3:PutObject" + PutObjectVersionAcl Action = "s3:PutObjectVersionAcl" + PutReplicationConfiguration Action = "s3:PutReplicationConfiguration" + RestoreObject Action = "s3:RestoreObject" ) -var AllowedActions = []action{ +var AllowedActions = []Action{ AbortMultipartUpload, DeleteObject, GetBucketLocation, @@ -88,11 +88,11 @@ var AllowedActions = []action{ PutLifecycleConfiguration, } -type effect string +type Effect string // effectAllow values are expected by the S3 API to be 'Allow' explicitly const ( - effectAllow effect = "Allow" + effectAllow Effect = "Allow" ) // PolicyStatment is the Go representation of a PolicyStatement json struct @@ -101,12 +101,12 @@ type PolicyStatement struct { // Sid (optional) is the PolicyStatement's unique identifier Sid string `json:"Sid"` // Effect determines whether the Action(s) are 'Allow'ed - Effect effect `json:"Effect"` + Effect Effect `json:"Effect"` // Principle is/are the nutanix user names affected by this PolicyStatement // Must be in the format of '' Principal map[string][]string `json:"Principal"` // Action is a list of s3:* actions - Action []action `json:"Action"` + Action []Action `json:"Action"` // Resource is the ARN identifier for the S3 resource (bucket) // Must be in the format of 'arn:aws:s3:::' Resource []string `json:"Resource"` @@ -179,6 +179,7 @@ func (bp *BucketPolicy) ModifyBucketPolicy(ps ...PolicyStatement) *BucketPolicy for j, oldP := range bp.Statement { if newP.Sid == oldP.Sid { bp.Statement[j] = newP + match = true } } if !match { @@ -217,7 +218,7 @@ func NewPolicyStatement() *PolicyStatement { Sid: "", Effect: "", Principal: map[string][]string{}, - Action: []action{}, + Action: []Action{}, Resource: []string{}, } } @@ -268,7 +269,7 @@ func (ps *PolicyStatement) Allows() *PolicyStatement { } // Actions is the set of "s3:*" actions for the PolicyStatement is concerned -func (ps *PolicyStatement) Actions(actions ...action) *PolicyStatement { +func (ps *PolicyStatement) Actions(actions ...Action) *PolicyStatement { ps.Action = actions return ps } @@ -278,7 +279,7 @@ func (ps *PolicyStatement) EjectPrincipals(users ...string) { for _, u := range users { for j, v := range principals { if u == v { - principals = append(principals[:j], principals[:j+1]...) + principals = append(principals[:j], principals[j+1:]...) } } } diff --git a/pkg/util/s3client/policy_test.go b/pkg/util/s3client/policy_test.go new file mode 100644 index 0000000..946c35f --- /dev/null +++ b/pkg/util/s3client/policy_test.go @@ -0,0 +1,221 @@ +package s3client_test + +import ( + "encoding/json" + "errors" + "testing" + + "github.com/nutanix-core/k8s-ntnx-object-cosi/pkg/util/s3client" + mocks "github.com/nutanix-core/k8s-ntnx-object-cosi/tests/fakes" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/s3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPutBucketPolicy(t *testing.T) { + t.Run("TestPutBucketPolicy_Success", func(t *testing.T) { + ps := s3client.PolicyStatement{ + Sid: "test-sid", + Effect: "Allow", + Principal: map[string][]string{"AWS": {"user1", "user2"}}, + Action: []s3client.Action{s3client.ListBucket, s3client.GetObject}, + Resource: []string{"arn:aws:s3:::test-bucket"}, + } + policy := s3client.NewBucketPolicy(ps) + + var capturedInput *s3.PutBucketPolicyInput + mockClient := &mocks.MockS3Client{ + PutBucketPolicyFunc: func(input *s3.PutBucketPolicyInput) (*s3.PutBucketPolicyOutput, error) { + capturedInput = input + return &s3.PutBucketPolicyOutput{}, nil + }, + } + agent := &s3client.S3Agent{Client: mockClient} + + out, err := agent.PutBucketPolicy("test-bucket", *policy) + require.NoError(t, err) + require.NotNil(t, out) + require.NotNil(t, capturedInput) + assert.Equal(t, "test-bucket", *capturedInput.Bucket) + + var unmarshalledPolicy s3client.BucketPolicy + err = json.Unmarshal([]byte(*capturedInput.Policy), &unmarshalledPolicy) + require.NoError(t, err) + require.Len(t, unmarshalledPolicy.Statement, 1) + assert.Equal(t, "test-sid", unmarshalledPolicy.Statement[0].Sid) + }) + + t.Run("TestPutBucketPolicy_Error", func(t *testing.T) { + expectedErr := errors.New("failed to put policy") + mockClient := &mocks.MockS3Client{ + PutBucketPolicyFunc: func(input *s3.PutBucketPolicyInput) (*s3.PutBucketPolicyOutput, error) { + return nil, expectedErr + }, + } + agent := &s3client.S3Agent{Client: mockClient} + policy := s3client.NewBucketPolicy() + out, err := agent.PutBucketPolicy("test-bucket", *policy) + assert.Error(t, err) + assert.Nil(t, out) + assert.Equal(t, expectedErr, err) + }) +} + +func TestGetBucketPolicy(t *testing.T) { + t.Run("TestGetBucketPolicy_Success", func(t *testing.T) { + ps := s3client.PolicyStatement{ + Sid: "test-sid", + Effect: "Allow", + Principal: map[string][]string{"AWS": {"userA"}}, + Action: []s3client.Action{s3client.GetBucketLocation}, + Resource: []string{"arn:aws:s3:::my-bucket"}, + } + policy := s3client.NewBucketPolicy(ps) + serialized, err := json.Marshal(policy) + require.NoError(t, err) + + mockClient := &mocks.MockS3Client{ + GetBucketPolicyFunc: func(input *s3.GetBucketPolicyInput) (*s3.GetBucketPolicyOutput, error) { + assert.Equal(t, "my-bucket", *input.Bucket) + return &s3.GetBucketPolicyOutput{ + Policy: aws.String(string(serialized)), + }, nil + }, + } + agent := &s3client.S3Agent{Client: mockClient} + retPolicy, err := agent.GetBucketPolicy("my-bucket") + require.NoError(t, err) + require.NotNil(t, retPolicy) + assert.Equal(t, policy.Version, retPolicy.Version) + require.Len(t, retPolicy.Statement, 1) + assert.Equal(t, "test-sid", retPolicy.Statement[0].Sid) + }) + + t.Run("TestGetBucketPolicy_S3Error", func(t *testing.T) { + mockClient := &mocks.MockS3Client{ + GetBucketPolicyFunc: func(input *s3.GetBucketPolicyInput) (*s3.GetBucketPolicyOutput, error) { + return nil, errors.New("get policy error") + }, + } + agent := &s3client.S3Agent{Client: mockClient} + policy, err := agent.GetBucketPolicy("nonexistent-bucket") + assert.Error(t, err) + assert.Nil(t, policy) + }) + + t.Run("TestGetBucketPolicy_UnmarshalError", func(t *testing.T) { + mockClient := &mocks.MockS3Client{ + GetBucketPolicyFunc: func(input *s3.GetBucketPolicyInput) (*s3.GetBucketPolicyOutput, error) { + return &s3.GetBucketPolicyOutput{Policy: aws.String("invalid-json")}, nil + }, + } + agent := &s3client.S3Agent{Client: mockClient} + policy, err := agent.GetBucketPolicy("bucket") + assert.Error(t, err) + assert.Nil(t, policy) + }) +} + +func TestModifyBucketPolicy(t *testing.T) { + t.Run("TestModifyBucketPolicy_UpdateAndAddStatements", func(t *testing.T) { + ps1 := s3client.PolicyStatement{Sid: "sid1", Effect: "Allow"} + ps2 := s3client.PolicyStatement{Sid: "sid2", Effect: "Allow"} + bp := s3client.NewBucketPolicy(ps1, ps2) + + newPS1 := s3client.PolicyStatement{Sid: "sid1", Effect: "Deny"} + newPS3 := s3client.PolicyStatement{Sid: "sid3", Effect: "Allow"} + + modified := bp.ModifyBucketPolicy(newPS1, newPS3) + require.Len(t, modified.Statement, 3) + + var foundSID1, foundSID2, foundSID3 bool + for _, ps := range modified.Statement { + switch ps.Sid { + case "sid1": + foundSID1 = true + assert.Equal(t, "Deny", string(ps.Effect)) + case "sid2": + foundSID2 = true + case "sid3": + foundSID3 = true + } + } + assert.True(t, foundSID1 && foundSID2 && foundSID3) + }) +} + +func TestDropPolicyStatements(t *testing.T) { + t.Run("TestDropPolicyStatements_RemovesCorrectSID", func(t *testing.T) { + ps1 := s3client.PolicyStatement{Sid: "sid1"} + ps2 := s3client.PolicyStatement{Sid: "sid2"} + ps3 := s3client.PolicyStatement{Sid: "sid3"} + bp := s3client.NewBucketPolicy(ps1, ps2, ps3) + + modified := bp.DropPolicyStatements("sid2") + require.Len(t, modified.Statement, 2) + for _, ps := range modified.Statement { + assert.NotEqual(t, "sid2", ps.Sid) + } + }) +} + +func TestEjectPrincipals(t *testing.T) { + t.Run("TestEjectPrincipals_RemovesPrincipal", func(t *testing.T) { + ps := s3client.PolicyStatement{ + Sid: "sid1", + Effect: "Allow", + Principal: map[string][]string{ + "AWS": {"user1", "user2", "user3"}, + }, + } + bp := s3client.NewBucketPolicy(ps) + + modified := bp.EjectPrincipals("user2") + require.Len(t, modified.Statement, 1) + principals := modified.Statement[0].Principal["AWS"] + assert.NotContains(t, principals, "user2") + assert.Contains(t, principals, "user1") + assert.Contains(t, principals, "user3") + }) +} + +func TestPolicyStatementMethods(t *testing.T) { + t.Run("TestPolicyStatementMethods_ForPrincipals", func(t *testing.T) { + ps := s3client.NewPolicyStatement() + ps.ForPrincipals("alice", "bob") + principals := ps.Principal["AWS"] + assert.Contains(t, principals, "alice") + assert.Contains(t, principals, "bob") + }) + + t.Run("TestPolicyStatementMethods_ForResources", func(t *testing.T) { + ps := s3client.NewPolicyStatement() + ps.ForResources("mybucket") + require.Len(t, ps.Resource, 1) + assert.Equal(t, "arn:aws:s3:::mybucket", ps.Resource[0]) + }) + + t.Run("TestPolicyStatementMethods_ForSubResources", func(t *testing.T) { + ps := s3client.NewPolicyStatement() + ps.ForSubResources("mybucket") + require.Len(t, ps.Resource, 1) + assert.Equal(t, "arn:aws:s3:::mybucket/*", ps.Resource[0]) + }) + + t.Run("TestPolicyStatementMethods_AllowsEffect", func(t *testing.T) { + ps := s3client.NewPolicyStatement() + ps.Allows() + assert.Equal(t, s3client.Effect("Allow"), ps.Effect) + ps.Effect = "Other" + ps.Allows() + assert.Equal(t, s3client.Effect("Other"), ps.Effect) + }) + + t.Run("TestPolicyStatementMethods_SetActions", func(t *testing.T) { + ps := s3client.NewPolicyStatement() + ps.Actions(s3client.GetBucketAcl, s3client.PutObject) + assert.ElementsMatch(t, []s3client.Action{s3client.GetBucketAcl, s3client.PutObject}, ps.Action) + }) +} diff --git a/pkg/util/s3client/s3-handlers.go b/pkg/util/s3client/s3-handlers.go index 7e76270..b4ea999 100644 --- a/pkg/util/s3client/s3-handlers.go +++ b/pkg/util/s3client/s3-handlers.go @@ -37,9 +37,26 @@ const ( ErrNoSuchBucket = "NoSuchBucket" ) +type S3iface interface { + CreateBucket(name string) error + DeleteBucket(name string) (bool, error) + GetBucketPolicy(bucket string) (*BucketPolicy, error) + PutBucketPolicy(bucket string, policy BucketPolicy) (*s3.PutBucketPolicyOutput, error) +} + +type S3client interface { + CreateBucket(input *s3.CreateBucketInput) (*s3.CreateBucketOutput, error) + DeleteBucket(input *s3.DeleteBucketInput) (*s3.DeleteBucketOutput, error) + DeleteObject(input *s3.DeleteObjectInput) (*s3.DeleteObjectOutput, error) + PutObject(input *s3.PutObjectInput) (*s3.PutObjectOutput, error) + GetObject(input *s3.GetObjectInput) (*s3.GetObjectOutput, error) + GetBucketPolicy(input *s3.GetBucketPolicyInput) (*s3.GetBucketPolicyOutput, error) + PutBucketPolicy(input *s3.PutBucketPolicyInput) (*s3.PutBucketPolicyOutput, error) +} + // S3Agent wraps the s3.S3 structure to allow for wrapper methods type S3Agent struct { - Client *s3.S3 + Client S3client } func NewS3Agent(accessKey, secretKey, endpoint, caCert string, insecure, debug bool) (*S3Agent, error) { @@ -91,10 +108,6 @@ func NewS3Agent(accessKey, secretKey, endpoint, caCert string, insecure, debug b // CreateBucket creates a bucket with the given name func (s *S3Agent) CreateBucket(name string) error { - return s.createBucket(name) -} - -func (s *S3Agent) createBucket(name string) error { klog.InfoS("Creating bucket", "name", name) bucketInput := &s3.CreateBucketInput{ @@ -126,9 +139,15 @@ func (s *S3Agent) DeleteBucket(name string) (bool, error) { Bucket: aws.String(name), }) if err != nil { + if aerr, ok := err.(awserr.Error); ok { + klog.InfoS("DEBUG: after s3 call", "ok", ok, "aerr", aerr) + if aerr.Code() == s3.ErrCodeNoSuchBucket { + klog.InfoS("Bucket does not exist", "name", name) + return true, nil + } + } klog.ErrorS(err, "failed to delete bucket") return false, err - } return true, nil } diff --git a/pkg/util/s3client/s3-handlers_test.go b/pkg/util/s3client/s3-handlers_test.go new file mode 100644 index 0000000..aeb8193 --- /dev/null +++ b/pkg/util/s3client/s3-handlers_test.go @@ -0,0 +1,324 @@ +package s3client_test + +import ( + "bytes" + "errors" + "io" + "net/http" + "testing" + "time" + + "github.com/nutanix-core/k8s-ntnx-object-cosi/pkg/util/s3client" + mocks "github.com/nutanix-core/k8s-ntnx-object-cosi/tests/fakes" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/awserr" + "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/s3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const validPEMCert = `-----BEGIN CERTIFICATE----- +MIIDVDCCAjygAwIBAgIRALLmZX4DorTkHZzNVhA+RYUwDQYJKoZIhvcNAQELBQAw +FzEVMBMGA1UEChMMTnV0YW5peCBJbmMuMCAXDTI1MDEyNzA4MDU0NVoYDzIyMDQw +NzAzMDgwNTQ1WjAXMRUwEwYDVQQKEwxOdXRhbml4IEluYy4wggEiMA0GCSqGSIb3 +DQEBAQUAA4IBDwAwggEKAoIBAQDXvGBSfAByt1VCvAuaDThq7H+JlK8He8zcjqD7 +DGe6Dc1jq9n7+eN6X+cTT85dKPhajjPp9Nc4g1HT0jfWHD4QHhgkS12Ny0Wrmqqr +qx8Fcuzhaz89BFyaI3tOCBx75yZe9zaNSOBoKJqreu2mAjfI8LM7jFl/cON68Kd9 +/b6g89+2FME0gFq2p3mabGXGVerFAK0g7TuffhWKyKL/B9equZ2M7CmhAO7wa0ko +CnQJY3XvNk4OUeb7NXBdpswpWD789rXSsSMbxaS9ZmDrqvB7A1IlWjwEUVzxYnPw +21miqfZ5qb2p3gZtSTbDOqmg0l9yfvwIroaGKMLrRCK0Q+IjAgMBAAGjgZgwgZUw +DgYDVR0PAQH/BAQDAgKkMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFPE9h85q +h53rKbrBRd+VnUGuAxpKMFMGA1UdEQRMMEqCInJpbXVydS5wcmlzbS1jZW50cmFs +LmNsdXN0ZXIubG9jYWyCJCoucmltdXJ1LnByaXNtLWNlbnRyYWwuY2x1c3Rlci5s +b2NhbDANBgkqhkiG9w0BAQsFAAOCAQEAd3wN98xaTJqBGE2j0qoSQhqMNb5NmBDa +Cp/Pt0mwlAJmv2petz3ON5edt3/yC81vEvWfT+4GpM/6jAHcY9rZ+XQA+ZkSnjsG +itALgSLq77vDYRTHAXfsWPH2DY140IS6OqqTtLPLukHzux5uR2LH1uggU5sARs5l +EBi1znwsnSxrKfqPOurt4oSgW7FougqiaOiK+Vkm+1FtybVlMXH1w5TkePFK/x7B +OkiKpPoALmPy1Y2BxvbpxQYLjZEFMKwIo7G20pl9opFntCBs6GcY7QNesVYKawV1 +zQEsbJYBuhj1XgjzRx+6al2Fjf2NFN3I2aCKQZ9oMFsg0R/M0biBjA== +-----END CERTIFICATE----- +` + +func TestNewS3Agent(t *testing.T) { + accessKey := "dummyAccessKey" + secretKey := "dummySecretKey" + + t.Run("TestNewS3Agent_ValidSecureConnection", func(t *testing.T) { + // Insecure false and endpoint starting with "https" should succeed with a valid PEM CA certificate. + endpoint := "https://127.0.0.1:9440" + agent, err := s3client.NewS3Agent(accessKey, secretKey, endpoint, validPEMCert, false, false) + require.NoError(t, err) + require.NotNil(t, agent) + require.NotNil(t, agent.Client) + + // Additionally, verify that the client was built using an HTTP client with a proper timeout. + sess, err := session.NewSession(aws.NewConfig(). + WithRegion("us-east-1"). + WithCredentials(credentials.NewStaticCredentials(accessKey, secretKey, "")). + WithEndpoint(endpoint). + WithS3ForcePathStyle(true). + WithMaxRetries(5). + WithDisableSSL(false). + WithHTTPClient(&http.Client{Timeout: time.Second * 15})) + require.NoError(t, err) + svc := s3.New(sess) + // Not a deep check, but ensures we can instantiate an s3 client. + assert.IsType(t, svc, agent.Client) + }) + + t.Run("TestNewS3Agent_ValidInsecureConnection", func(t *testing.T) { + // When insecure is true, even if the endpoint starts with "http", it should succeed. + endpoint := "http://127.0.0.1:9440" + agent, err := s3client.NewS3Agent(accessKey, secretKey, endpoint, validPEMCert, true, false) + require.NoError(t, err) + require.NotNil(t, agent) + require.NotNil(t, agent.Client) + + // Additionally, verify that the client was built using an HTTP client with a proper timeout. + sess, err := session.NewSession(aws.NewConfig(). + WithRegion("us-east-1"). + WithCredentials(credentials.NewStaticCredentials(accessKey, secretKey, "")). + WithEndpoint(endpoint). + WithS3ForcePathStyle(true). + WithMaxRetries(5). + WithDisableSSL(false). + WithHTTPClient(&http.Client{Timeout: time.Second * 15})) + require.NoError(t, err) + svc := s3.New(sess) + // Not a deep check, but ensures we can instantiate an s3 client. + assert.IsType(t, svc, agent.Client) + }) + + t.Run("TestNewS3Agent_ErrorSecureWithHttpEndpoint", func(t *testing.T) { + // When insecure is false but the endpoint starts with "http", it should return an error. + endpoint := "http://127.0.0.1:9440" + agent, err := s3client.NewS3Agent(accessKey, secretKey, endpoint, validPEMCert, false, false) + require.Error(t, err) + assert.Nil(t, agent) + assert.Contains(t, err.Error(), "'http' endpoint cannot be secure") + }) + + t.Run("TestNewS3Agent_ErrorInvalidCACert", func(t *testing.T) { + // Provide an invalid CA cert string that is not a valid PEM or base64 string. + endpoint := "https://127.0.0.1:9440" + invalidCACert := "not-base64" + agent, err := s3client.NewS3Agent(accessKey, secretKey, endpoint, invalidCACert, false, false) + require.Error(t, err) + assert.Nil(t, agent) + // The error should come from transport.BuildTransportTLS. We expect an error related to decoding the CA cert. + assert.Contains(t, err.Error(), "failed to decode CA cert") + }) + + t.Run("TestNewS3Agent_DebugMode", func(t *testing.T) { + // When debug is true, log level should be set to aws.LogDebug. + // While we cannot easily inspect the AWS config from the created session, + // we can at least verify that the agent creation does not error. + endpoint := "https://127.0.0.1:9440" + agent, err := s3client.NewS3Agent(accessKey, secretKey, endpoint, validPEMCert, false, true) + require.NoError(t, err) + require.NotNil(t, agent) + }) +} + +func TestCreateBucket(t *testing.T) { + t.Run("TestCreateBucket_Success", func(t *testing.T) { + mockClient := &mocks.MockS3Client{ + CreateBucketFunc: func(input *s3.CreateBucketInput) (*s3.CreateBucketOutput, error) { + assert.Equal(t, "test-bucket", *input.Bucket) + return &s3.CreateBucketOutput{}, nil + }, + } + s := &s3client.S3Agent{Client: mockClient} + err := s.CreateBucket("test-bucket") + assert.NoError(t, err) + }) + + t.Run("TestCreateBucket_AlreadyExists", func(t *testing.T) { + mockClient := &mocks.MockS3Client{ + CreateBucketFunc: func(input *s3.CreateBucketInput) (*s3.CreateBucketOutput, error) { + return nil, awserr.New(s3.ErrCodeBucketAlreadyExists, "bucket exists", nil) + }, + } + s := &s3client.S3Agent{Client: mockClient} + err := s.CreateBucket("existing-bucket") + assert.NoError(t, err) + }) + + t.Run("TestCreateBucket_AlreadyOwned", func(t *testing.T) { + mockClient := &mocks.MockS3Client{ + CreateBucketFunc: func(input *s3.CreateBucketInput) (*s3.CreateBucketOutput, error) { + return nil, awserr.New(s3.ErrCodeBucketAlreadyOwnedByYou, "already owned", nil) + }, + } + s := &s3client.S3Agent{Client: mockClient} + err := s.CreateBucket("owned-bucket") + assert.NoError(t, err) + }) + + t.Run("TestCreateBucket_GenericError", func(t *testing.T) { + mockClient := &mocks.MockS3Client{ + CreateBucketFunc: func(input *s3.CreateBucketInput) (*s3.CreateBucketOutput, error) { + return nil, errors.New("unexpected error") + }, + } + s := &s3client.S3Agent{Client: mockClient} + err := s.CreateBucket("fail-bucket") + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to create bucket") + assert.Contains(t, err.Error(), "unexpected error") + }) +} + +func TestDeleteBucket(t *testing.T) { + t.Run("DeleteBucket_Success", func(t *testing.T) { + mockClient := &mocks.MockS3Client{ + DeleteBucketFunc: func(input *s3.DeleteBucketInput) (*s3.DeleteBucketOutput, error) { + assert.Equal(t, "my-bucket", *input.Bucket) + return &s3.DeleteBucketOutput{}, nil + }, + } + s := &s3client.S3Agent{Client: mockClient} + ok, err := s.DeleteBucket("my-bucket") + assert.NoError(t, err) + assert.True(t, ok) + }) + + t.Run("DeleteBucket_NoSuchBucket", func(t *testing.T) { + mockClient := &mocks.MockS3Client{ + DeleteBucketFunc: func(input *s3.DeleteBucketInput) (*s3.DeleteBucketOutput, error) { + return &s3.DeleteBucketOutput{}, awserr.New(s3.ErrCodeNoSuchBucket, "The specified bucket does not exist.", nil) + }, + } + s := &s3client.S3Agent{Client: mockClient} + ok, err := s.DeleteBucket("my-bucket") + assert.NoError(t, err) + assert.True(t, ok) + }) + + t.Run("DeleteBucket_Error", func(t *testing.T) { + mockClient := &mocks.MockS3Client{ + DeleteBucketFunc: func(input *s3.DeleteBucketInput) (*s3.DeleteBucketOutput, error) { + return nil, errors.New("delete failed") + }, + } + s := &s3client.S3Agent{Client: mockClient} + ok, err := s.DeleteBucket("bad-bucket") + assert.Error(t, err) + assert.False(t, ok) + assert.Contains(t, err.Error(), "delete failed") + }) +} + +func TestPutObjectInBucket(t *testing.T) { + t.Run("PutObjectInBucket_Success", func(t *testing.T) { + mock := &mocks.MockS3Client{ + PutObjectFunc: func(input *s3.PutObjectInput) (*s3.PutObjectOutput, error) { + assert.Equal(t, "test-bucket", *input.Bucket) + assert.Equal(t, "test-key", *input.Key) + assert.Equal(t, "text/plain", *input.ContentType) + return &s3.PutObjectOutput{}, nil + }, + } + s := &s3client.S3Agent{Client: mock} + ok, err := s.PutObjectInBucket("test-bucket", "hello", "test-key", "text/plain") + assert.NoError(t, err) + assert.True(t, ok) + }) + + t.Run("PutObjectInBucket_Error", func(t *testing.T) { + mock := &mocks.MockS3Client{ + PutObjectFunc: func(input *s3.PutObjectInput) (*s3.PutObjectOutput, error) { + return nil, errors.New("put error") + }, + } + s := &s3client.S3Agent{Client: mock} + ok, err := s.PutObjectInBucket("fail-bucket", "fail", "fail-key", "text/plain") + assert.Error(t, err) + assert.False(t, ok) + }) +} + +func TestGetObjectInBucket(t *testing.T) { + t.Run("GetObjectInBucket_Success", func(t *testing.T) { + mock := &mocks.MockS3Client{ + GetObjectFunc: func(input *s3.GetObjectInput) (*s3.GetObjectOutput, error) { + assert.Equal(t, "test-bucket", *input.Bucket) + assert.Equal(t, "test-key", *input.Key) + return &s3.GetObjectOutput{ + Body: io.NopCloser(bytes.NewBufferString("test-content")), + }, nil + }, + } + s := &s3client.S3Agent{Client: mock} + content, err := s.GetObjectInBucket("test-bucket", "test-key") + assert.NoError(t, err) + assert.Equal(t, "test-content", content) + }) + + t.Run("GetObjectInBucket_NotFound", func(t *testing.T) { + mock := &mocks.MockS3Client{ + GetObjectFunc: func(input *s3.GetObjectInput) (*s3.GetObjectOutput, error) { + return nil, errors.New("not found") + }, + } + s := &s3client.S3Agent{Client: mock} + content, err := s.GetObjectInBucket("missing-bucket", "missing-key") + assert.Error(t, err) + assert.Equal(t, "ERROR_ OBJECT NOT FOUND", content) + }) +} + +func TestDeleteObjectInBucket(t *testing.T) { + t.Run("DeleteObjectInBucket_Success", func(t *testing.T) { + mock := &mocks.MockS3Client{ + DeleteObjectFunc: func(input *s3.DeleteObjectInput) (*s3.DeleteObjectOutput, error) { + assert.Equal(t, "test-bucket", *input.Bucket) + assert.Equal(t, "test-key", *input.Key) + return &s3.DeleteObjectOutput{}, nil + }, + } + s := &s3client.S3Agent{Client: mock} + ok, err := s.DeleteObjectInBucket("test-bucket", "test-key") + assert.NoError(t, err) + assert.True(t, ok) + }) + + t.Run("DeleteObjectInBucket_NoSuchBucket", func(t *testing.T) { + mock := &mocks.MockS3Client{ + DeleteObjectFunc: func(input *s3.DeleteObjectInput) (*s3.DeleteObjectOutput, error) { + return nil, awserr.New(s3.ErrCodeNoSuchBucket, "bucket not found", nil) + }, + } + s := &s3client.S3Agent{Client: mock} + ok, err := s.DeleteObjectInBucket("no-bucket", "key") + assert.NoError(t, err) + assert.True(t, ok) + }) + + t.Run("DeleteObjectInBucket_NoSuchKey", func(t *testing.T) { + mock := &mocks.MockS3Client{ + DeleteObjectFunc: func(input *s3.DeleteObjectInput) (*s3.DeleteObjectOutput, error) { + return nil, awserr.New(s3.ErrCodeNoSuchKey, "key not found", nil) + }, + } + s := &s3client.S3Agent{Client: mock} + ok, err := s.DeleteObjectInBucket("bucket", "no-key") + assert.NoError(t, err) + assert.True(t, ok) + }) + + t.Run("DeleteObjectInBucket_Error", func(t *testing.T) { + mock := &mocks.MockS3Client{ + DeleteObjectFunc: func(input *s3.DeleteObjectInput) (*s3.DeleteObjectOutput, error) { + return nil, errors.New("delete error") + }, + } + s := &s3client.S3Agent{Client: mock} + ok, err := s.DeleteObjectInBucket("bucket", "key") + assert.Error(t, err) + assert.False(t, ok) + }) +} diff --git a/pkg/util/transport/transport_test.go b/pkg/util/transport/transport_test.go new file mode 100644 index 0000000..1fbf1ff --- /dev/null +++ b/pkg/util/transport/transport_test.go @@ -0,0 +1,121 @@ +package transport + +import ( + "encoding/base64" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const invalidPEMCert = `-----BEGIN CERTIFICATE----- +MIIBszCCAVmgAwIBAgIUQWnMEj6V3RQI9z5Hc3+qGKDdJOQwCgYIKoZIzj0EAwIw +EjEQMA4GA1UEAwwHcm9vdENBMB4XDTIxMDUyMTA5MjEyOFoXDTMxMDUxOTA5MjEy +OFowEjEQMA4GA1UEAwwHcm9vdENBMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAExHa7 +XjgUacgMRR9w+OXYy1Pu67n0LlgFzqX+gDVoEWSODVd19Bx8I59M/TJjSzE0sF8T +twUjg6ezd9bDSR2FyyHRt4Nuv6R/kD3b9M+oH10wczELMAkGA1UdEwQCMAAwDgYD +VR0PAQH/BAQDAgeAMB0GA1UdDgQWBBR29oUT2Yw+xzd6OIp5A/HVnhNhYjAfBgNV +HSMEGDAWgBR29oUT2Yw+xzd6OIp5A/HVnhNhYjAKBggqhkjOPQQDAgNHADBEAiAk +F4eGc8JHSKZ6KU9QHfn3ev95tr6NV0uXwB+5Ciu4kwIgCS48S/jHcUuR+EoXfzX2 +Q6KhqCcBnBJoBfaAsV7tSFA= +-----END CERTIFICATE-----` + +const validPEMCert = `-----BEGIN CERTIFICATE----- +MIIDCTCCAfGgAwIBAgIUHUPkerrOvHfqMIQRJOXMyt9Db90wDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI1MDUwNDEwMjI1MFoXDTI2MDUw +NDEwMjI1MFowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEA1mPxcihKFcjNUsQCx/3EsXib2tdRnfXdwhBeGtfPEfPD +6PRFwK1BaJECv7h5MYr3lmQlSYXaM69SFcE6jBb/SOFOTknbViHS18AnoGVCMb9w +nJP2cosv00b+Do1Lkh3vHlhn4KovlT1xquNkHDDRe1TjKec0aZcXVF4kkebGIXEs +FH01nItvaB/msKrKSZZVvELrgsJdB5WQwUOB4Eq/WlCDvdtHK+TxgJ676iJl/Cz8 +8ZaFxz2DWQrPdqQ78BU0Min6XbqLg+kE+NXJ4nglWGmPxlatyNrelF9ZLssBr2Mo +Rmce6d9WneN7iJkuWn2YKPdUMpGN+h5LolfXmD4psQIDAQABo1MwUTAdBgNVHQ4E +FgQUXk6pM2YNBr9XpdV2ELAzcdakzKIwHwYDVR0jBBgwFoAUXk6pM2YNBr9XpdV2 +ELAzcdakzKIwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAXsoZ +yzhrtsKyHx8XZHXlQySIqudnE90jHBw//okxZdQazK2YyKcMbXZy5723HNvnpmaj +QnkRvTDATv4pBcjJvAjlImo3C2qG96Fv6ySuuhFZcjqi2syPXCq/U9krcf6khtEs +N0EJZ6zIKotGnGLsKH62OZr60eY7IXbPqCUugp7h+RUNIoowh6tb526/2OiSef4S +4YEuOW4Mt3f9OUSw5efr2OoJRC44nZjeIKSqhs6gZJ33DstUQcP7gkQfmo113I9h +lP+COEFL7FKshkfwdns1T5lzmL6fxIghDdX1Wv5TF22qH2Iuz/AuZDAzrfzOk+yL +xsuQAC8/6BO0r72KGw== +-----END CERTIFICATE----- +` + +func TestBuildTransportTLS(t *testing.T) { + + t.Run("BuildTransportTLS_InsecureTransport", func(t *testing.T) { + cfg := TlsConfig{ + Insecure: true, + Endpoint: "https://example.com", + } + transport, err := BuildTransportTLS(cfg) + require.NoError(t, err) + require.NotNil(t, transport) + require.NotNil(t, transport.TLSClientConfig) + assert.True(t, transport.TLSClientConfig.InsecureSkipVerify, "InsecureSkipVerify should be true") + }) + + t.Run("BuildTransportTLS_ValidPEM", func(t *testing.T) { + cfg := TlsConfig{ + Insecure: false, + CACert: validPEMCert, + Endpoint: "https://example.com", + } + transport, err := BuildTransportTLS(cfg) + require.NoError(t, err) + require.NotNil(t, transport) + require.NotNil(t, transport.TLSClientConfig) + assert.False(t, transport.TLSClientConfig.InsecureSkipVerify, "InsecureSkipVerify should be false") + }) + + t.Run("BuildTransportTLS_EncodedPEM", func(t *testing.T) { + encoded := base64.StdEncoding.EncodeToString([]byte(validPEMCert)) + cfg := TlsConfig{ + Insecure: false, + CACert: encoded, + Endpoint: "https://example.com", + } + transport, err := BuildTransportTLS(cfg) + require.NoError(t, err) + require.NotNil(t, transport) + require.NotNil(t, transport.TLSClientConfig) + assert.False(t, transport.TLSClientConfig.InsecureSkipVerify, "InsecureSkipVerify should be false") + }) + + t.Run("BuildTransportTLS_InvalidBase64", func(t *testing.T) { + cfg := TlsConfig{ + Insecure: false, + CACert: "not-base64", + Endpoint: "https://example.com", + } + transport, err := BuildTransportTLS(cfg) + require.Error(t, err) + assert.Nil(t, transport) + assert.Contains(t, err.Error(), "failed to decode CA cert") + }) + + t.Run("BuildTransportTLS_InvalidPEM", func(t *testing.T) { + invalidPEM := invalidPEMCert + encoded := base64.StdEncoding.EncodeToString([]byte(invalidPEM)) + cfg := TlsConfig{ + Insecure: false, + CACert: encoded, + Endpoint: "https://example.com", + } + transport, err := BuildTransportTLS(cfg) + require.Error(t, err) + assert.Nil(t, transport) + assert.Contains(t, err.Error(), "failed to append CA cert") + }) + + t.Run("BuildTransportTLS_MissingPEM", func(t *testing.T) { + cfg := TlsConfig{ + Insecure: false, + Endpoint: "https://example.com", + } + transport, err := BuildTransportTLS(cfg) + require.Error(t, err) + assert.Nil(t, transport) + assert.Contains(t, err.Error(), "failed to append CA cert") + }) +} diff --git a/project/resources/secret.yaml b/project/resources/secret.yaml index ef71c1e..d2789f9 100644 --- a/project/resources/secret.yaml +++ b/project/resources/secret.yaml @@ -15,9 +15,11 @@ stringData: ACCESS_KEY: "" # Admin IAM Secret key to be used for Nutanix Objects SECRET_KEY: "" - # PC Credentials in format :::. - # eg. "10.51.142.125:9440:user:password" - PC_SECRET: ":::" + # Prism Central endpoint, eg. "https://10.51.149.82:9440" + PC_ENDPOINT: "" + # PC Credentials in format :. + # eg. "user:password" + PC_SECRET: ":" # Controls whether certificate chain will be validated for S3 endpoint # If INSECURE is set to true, an insecure connection will be made with # the S3 endpoint (Certs will not be used) diff --git a/project/resources/triton.yaml b/project/resources/triton.yaml new file mode 100644 index 0000000..168244f --- /dev/null +++ b/project/resources/triton.yaml @@ -0,0 +1,51 @@ +apiVersion: v1 +kind: Pod +metadata: + name: objects-triton + labels: + app: objects-triton +spec: + containers: + - name: objects-triton + image: objects-triton:debug + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 7200 + - name: https + containerPort: 7201 + - name: iam-mock + containerPort: 5556 + env: + - name: ACCEPT_EULA + value: "Y" + volumeMounts: + - name: triton-data + mountPath: /home/nutanix/data + securityContext: + privileged: true + volumes: + - name: triton-data + emptyDir: {} +--- +apiVersion: v1 +kind: Service +metadata: + name: objects-triton-svc +spec: + type: NodePort + selector: + app: objects-triton + ports: + - name: http + port: 80 + targetPort: 7200 + nodePort: 30720 + - name: https + port: 443 + targetPort: 7201 + nodePort: 30721 + - name: iam-mock + port: 5556 + targetPort: 5556 + nodePort: 30556 \ No newline at end of file diff --git a/scripts/setup_test_env.sh b/scripts/setup_test_env.sh new file mode 100755 index 0000000..0ed54ef --- /dev/null +++ b/scripts/setup_test_env.sh @@ -0,0 +1,209 @@ +#!/bin/bash + +usage() { + echo "Usage: $0 [options]" + echo "" + echo "Options:" + echo " -t, --use_triton Use this flag to deploy triton image to be used as objectstore." + echo " -n, --namespace NAMESAPCE CLuster namespace for the COSI deployment [default = cosi]" + echo " -o, --oss_endpoint ENDPOINT Nutanix Object Store instance endpoint, eg. "http://10.51.142.82:80"." + echo " -i, --pc_endpoint ENDPOINT Prism Central endpoint, eg. "https://10.51.142.82:9440"." + echo " -u, --pc_user USERNAME Prism Central username. [default = admin]" + echo " -p, --pc_pass PASSWORD Prism Central password." + echo " -a, --access_key KEY Admin IAM Access key to be used for Nutanix Objects." + echo " -s, --secret_key KEY Admin IAM Secret key to be used for Nutanix Objects." + echo " -h, --help Display this help and exit." + echo "" + exit 1 +} + +USE_TRITON="" +NODE_IP="" +DRIVER_NAMESPACE="cosi" +OSS_ENDPOINT="" +PC_ENDPOINT="" +PC_USERNAME="admin" +PC_PASSWORD="" +ACCESS_KEY="" +SECRET_KEY="" + +while [[ "$#" -gt 0 ]]; do + case "$1" in + -t|--use_triton) + USE_TRITON="true" + shift 1 + ;; + -n|--namespace) + DRIVER_NAMESPACE="$2" + shift 2 + ;; + -o|--oss_endpoint) + OSS_ENDPOINT="$2" + shift 2 + ;; + -i|--pc_endpoint) + PC_ENDPOINT="$2" + shift 2 + ;; + -u|--pc_user) + PC_USERNAME="$2" + shift 2 + ;; + -p|--pc_pass) + PC_PASSWORD="$2" + shift 2 + ;; + -a|--access_key) + ACCESS_KEY="$2" + shift 2 + ;; + -s|--secret_key) + SECRET_KEY="$2" + shift 2 + ;; + -h|--help) + usage + ;; + *) + echo "[ERROR] Unknown option: $1" + usage + ;; + esac +done + +if [[ -z $USE_TRITON ]]; then + if [[ -z "$OSS_ENDPOINT" ]]; then + echo "[ERROR] --oss_endpoint is required." + usage + fi + + if [[ -z "$PC_ENDPOINT" ]]; then + echo "[ERROR] --pc_endpoint is required." + usage + fi + + if [[ -z "$PC_PASSWORD" ]]; then + echo "[ERROR] --pc_pass is required." + usage + fi + + if [[ -z "$ACCESS_KEY" ]]; then + echo "[ERROR] --access_key is required." + usage + fi + + if [[ -z "$SECRET_KEY" ]]; then + echo "[ERROR] --secret_key is required." + usage + fi +fi + +echo "[INFO] Verifying Kubernetes cluster." +if ! kubectl cluster-info > /dev/null 2>&1; then + echo "[ERROR] Unable to connect to Kubernetes cluster." + exit 1 +fi +echo "[INFO] Cluster is running." + +kubectl config view > /tmp/cosi-kubeconfig.yaml +echo "[INFO] kubeconfig file stored at '/tmp/cosi-kubeconfig.yaml'" + +echo "[INFO] Removing finalizers from CRs" +for resource in $(kubectl get secret -n="${DRIVER_NAMESPACE}" -o=jsonpath='{.items[*].metadata.name}' 2> /dev/null); +do + kubectl patch secret -n="${DRIVER_NAMESPACE}" "${resource}" -p='{"metadata":{"finalizers":null}}' --type=merge > /dev/null 2>&1 +done + +for resource in $(kubectl get bucketclaim.objectstorage.k8s.io -n="${DRIVER_NAMESPACE}" -o=jsonpath='{.items[*].metadata.name}' 2> /dev/null); +do + kubectl patch bucketclaim.objectstorage.k8s.io -n="${DRIVER_NAMESPACE}" "${resource}" -p='{"metadata":{"finalizers":null}}' --type=merge > /dev/null 2>&1 +done + +for resource in $(kubectl get bucketaccess.objectstorage.k8s.io -n="${DRIVER_NAMESPACE}" -o=jsonpath='{.items[*].metadata.name}' 2> /dev/null); +do + kubectl patch bucketaccess.objectstorage.k8s.io -n="${DRIVER_NAMESPACE}" "${resource}" -p='{"metadata":{"finalizers":null}}' --type=merge > /dev/null 2>&1 +done + +for resource in $(kubectl get bucket.objectstorage.k8s.io -n="${DRIVER_NAMESPACE}" -o=jsonpath='{.items[*].metadata.name}' 2> /dev/null); +do + kubectl patch bucket.objectstorage.k8s.io -n="${DRIVER_NAMESPACE}" "${resource}" -p='{"metadata":{"finalizers":null}}' --type=merge > /dev/null 2>&1 +done + +for resource in $(kubectl get bucketaccessclass.objectstorage.k8s.io -n="${DRIVER_NAMESPACE}" -o=jsonpath='{.items[*].metadata.name}' 2> /dev/null); +do + kubectl patch bucketaccessclass.objectstorage.k8s.io -n="${DRIVER_NAMESPACE}" "${resource}" -p='{"metadata":{"finalizers":null}}' --type=merge > /dev/null 2>&1 +done + +for resource in $(kubectl get bucketclass.objectstorage.k8s.io -n="${DRIVER_NAMESPACE}" -o=jsonpath='{.items[*].metadata.name}' 2> /dev/null); +do + kubectl patch bucketclass.objectstorage.k8s.io -n="${DRIVER_NAMESPACE}" "${resource}" -p='{"metadata":{"finalizers":null}}' --type=merge > /dev/null 2>&1 +done + +if kubectl get namespace "$DRIVER_NAMESPACE" > /dev/null 2>&1; then + echo "[INFO] Cleaning namespace: $DRIVER_NAMESPACE" + kubectl delete ns $DRIVER_NAMESPACE > /dev/null 2>&1 +fi + +echo "[INFO] Creating namespace: $DRIVER_NAMESPACE" +kubectl create ns $DRIVER_NAMESPACE > /dev/null 2>&1 + +echo "[INFO] Cleaning CRs in default namespace" +kubectl delete bucketclasses.objectstorage.k8s.io --all > /dev/null 2>&1 +kubectl delete bucketaccessclasses.objectstorage.k8s.io --all > /dev/null 2>&1 +kubectl delete buckets.objectstorage.k8s.io --all > /dev/null 2>&1 + +if [[ -n $USE_TRITON ]]; then + echo "[INFO] Deploying triton on cluster" + kubectl apply -f ./project/resources/triton.yaml -n "${DRIVER_NAMESPACE}" > /dev/null 2>&1 + + NODE_IP=$(kubectl get nodes -o jsonpath='{.items[0].status.addresses[?(@.type=="InternalIP")].address}') + OSS_ENDPOINT="http://objects-triton-svc:80" + ACCESS_KEY="Nutanix" + SECRET_KEY="Nutanix" + PC_ENDPOINT="http://objects-triton-svc:5556" + PC_PASSWORD="password" +fi + +echo "[INFO] Installing the COSI Helm chart from ./charts" +helm install cosi-driver -n "${DRIVER_NAMESPACE}" ./charts/ \ + --set=image.tag=latest \ + --set=secret.endpoint="${OSS_ENDPOINT}" \ + --set=secret.access_key="${ACCESS_KEY}" \ + --set=secret.secret_key="${SECRET_KEY}" \ + --set=secret.pc_endpoint="${PC_ENDPOINT}" \ + --set=secret.pc_username="${PC_USERNAME}" \ + --set=secret.pc_password="${PC_PASSWORD}" \ + --set=tls.s3.insecure=true \ + --set=tls.pc.insecure=true > /dev/null 2>&1 + +echo "[INFO] Waiting for deployment to be available." +kubectl wait --for=condition=available --timeout=60s --namespace="${DRIVER_NAMESPACE}" deployments objectstorage-provisioner > /dev/null 2>&1 + +echo "[INFO] Exporting environment variables" +export KUBECONFIG="/tmp/cosi-kubeconfig.yaml" +export DRIVER_NAMESPACE="${DRIVER_NAMESPACE}" +export OSS_ENDPOINT="${OSS_ENDPOINT}" +export PC_ENDPOINT="${PC_ENDPOINT}" +export PC_USERNAME="${PC_USERNAME}" +export PC_PASSWORD="${PC_PASSWORD}" +export ACCESS_KEY="${ACCESS_KEY}" +export SECRET_KEY="${SECRET_KEY}" +export NODE_IP="${NODE_IP}" +export USE_TRITON="${USE_TRITON}" + +echo "[INFO] Test environment is ready!" + +echo "[INFO] Starting E2E tests" +make e2e-tests + +echo "[INFO] E2E Tests completed" + +echo "[INFO] Cleaning up:" +echo "[INFO] Uninstalling COSI Driver" +helm uninstall cosi-driver -n "${DRIVER_NAMESPACE}" > /dev/null 2>&1 +if [[ -n $USE_TRITON ]]; then + echo "[INFO] Deleting triton from cluster" + kubectl delete -f ./project/resources/triton.yaml -n "${DRIVER_NAMESPACE}" > /dev/null 2>&1 +fi +echo "[INFO] Deleting /tmp/cosi-kubeconfig.yaml file" +rm -f /tmp/cosi-kubeconfig.yaml > /dev/null 2>&1 \ No newline at end of file diff --git a/tests/e2e/create_suite_test.go b/tests/e2e/create_suite_test.go new file mode 100644 index 0000000..079540f --- /dev/null +++ b/tests/e2e/create_suite_test.go @@ -0,0 +1,138 @@ +//go:build e2e_test + +package e2e_test + +import ( + "context" + + "github.com/aws/aws-sdk-go/service/s3" + helpers "github.com/nutanix-core/k8s-ntnx-object-cosi/tests/e2e/helpers" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/container-object-storage-interface-api/apis/objectstorage/v1alpha1" +) + +var _ = Describe("Create Bucket", func() { + var ( + bucketClass *v1alpha1.BucketClass + bucketClaim *v1alpha1.BucketClaim + invalidBucketClaim *v1alpha1.BucketClaim + bucket *v1alpha1.Bucket + ) + + BeforeEach(func(ctx context.Context) { + bucketClass = &v1alpha1.BucketClass{ + TypeMeta: metav1.TypeMeta{ + Kind: "BucketClass", + APIVersion: "objectstorage.k8s.io/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "create-bucketclass", + }, + DeletionPolicy: v1alpha1.DeletionPolicyDelete, + DriverName: "ntnx.objectstorage.k8s.io", + } + + bucketClaim = &v1alpha1.BucketClaim{ + TypeMeta: metav1.TypeMeta{ + Kind: "BucketClaim", + APIVersion: "objectstorage.k8s.io/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "create-bucketclaim", + Namespace: namespace, + }, + Spec: v1alpha1.BucketClaimSpec{ + BucketClassName: "create-bucketclass", + Protocols: []v1alpha1.Protocol{ + v1alpha1.ProtocolS3, + }, + }, + } + + invalidBucketClaim = &v1alpha1.BucketClaim{ + TypeMeta: metav1.TypeMeta{ + Kind: "BucketClaim", + APIVersion: "objectstorage.k8s.io/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "invalid-create-bucketclaim", + Namespace: namespace, + }, + Spec: v1alpha1.BucketClaimSpec{ + BucketClassName: "invalid-create-bucketclass", + Protocols: []v1alpha1.Protocol{ + v1alpha1.ProtocolS3, + }, + }, + } + }) + + When("Valid BucketClaim is used", func() { + It("Successfully creates a bucket", func(ctx context.Context) { + By("Creating a BucketClass resource from 'create-bucketclass") + _, err := bucketClient.ObjectstorageV1alpha1().BucketClasses().Create(ctx, bucketClass, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(func(ctx context.Context) { + _ = bucketClient.ObjectstorageV1alpha1().BucketClasses().Delete(ctx, bucketClass.Name, metav1.DeleteOptions{}) + }) + + By("Creating a BucketClaim resource from 'create-bucketclaim'") + bucketClaim, err := bucketClient.ObjectstorageV1alpha1().BucketClaims(bucketClaim.Namespace).Create(ctx, bucketClaim, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(func(ctx context.Context) { + _ = bucketClient.ObjectstorageV1alpha1().BucketClaims(bucketClaim.Namespace).Delete(ctx, bucketClaim.Name, metav1.DeleteOptions{}) + err = helpers.CheckBucketDeletionInObjectstore(ctx, s3Client, bucket.Name) + }) + + By("Checking if Bucket CR is created") + bucket, err = helpers.GetBucket(ctx, bucketClient, bucketClaim) + Expect(err).ToNot(HaveOccurred()) + Expect(bucket).ToNot(BeNil()) + + By("Checking if Bucket references the 'create-bucketclass' and 'create-bucketclaim'") + Expect(bucket.Spec.BucketClassName).To(Equal(bucketClass.Name)) + Expect(bucket.Spec.BucketClaim.Name).To(Equal(bucketClaim.Name)) + + By("Checking if Bucket status is 'bucketReady'") + err = helpers.CheckBucketStatusReady(ctx, bucketClient, bucket.Name) + Expect(err).ToNot(HaveOccurred()) + + By("Checking if Bucket is created in the Objectstore backend") + err = helpers.CheckBucketExistenceInObjectstore(ctx, s3Client, bucket.Name) + Expect(err).ToNot(HaveOccurred()) + }) + }) + + When("Invalid BucketClaim is used", func() { + It("Fails to create a bucket", func(ctx context.Context) { + By("Counting number of Buckets in the Objectstore backend before running the test") + out, err := s3Client.ListBuckets(&s3.ListBucketsInput{}) + Expect(err).ToNot(HaveOccurred()) + oldBucketsCount := len(out.Buckets) + + By("Creating a BucketClaim resource from 'invalid-create-bucketclaim'") + invalidBucketClaim, err := bucketClient.ObjectstorageV1alpha1().BucketClaims(invalidBucketClaim.Namespace).Create(ctx, invalidBucketClaim, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(func(ctx context.Context) { + _ = bucketClient.ObjectstorageV1alpha1().BucketClaims(invalidBucketClaim.Namespace).Delete(ctx, invalidBucketClaim.Name, metav1.DeleteOptions{}) + }) + + By("Checking if status of BucketClaim is empty") + invalidBucketClaim, err = helpers.GetBucketClaim(ctx, bucketClient, invalidBucketClaim) + Expect(err).ToNot(HaveOccurred()) + Expect(invalidBucketClaim.Status.BucketName).To(BeEmpty()) + + By("Checking if Bucket is not created in the Objectstore backend") + out, err = s3Client.ListBuckets(&s3.ListBucketsInput{}) + Expect(err).ToNot(HaveOccurred()) + bucketsCount := len(out.Buckets) + Expect(bucketsCount).To(BeNumerically("<=", oldBucketsCount)) + }) + }) +}) diff --git a/tests/e2e/delete_suite_test.go b/tests/e2e/delete_suite_test.go new file mode 100644 index 0000000..1980c9d --- /dev/null +++ b/tests/e2e/delete_suite_test.go @@ -0,0 +1,249 @@ +//go:build e2e_test + +package e2e_test + +import ( + "context" + + "github.com/aws/aws-sdk-go/service/s3" + helpers "github.com/nutanix-core/k8s-ntnx-object-cosi/tests/e2e/helpers" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/container-object-storage-interface-api/apis/objectstorage/v1alpha1" +) + +var _ = Describe("Delete Bucket", func() { + var ( + deleteBucketClass *v1alpha1.BucketClass + failBucketClass *v1alpha1.BucketClass + deleteBucketClaim *v1alpha1.BucketClaim + retainBucketClass *v1alpha1.BucketClass + retainBucketClaim *v1alpha1.BucketClaim + failBucketClaim *v1alpha1.BucketClaim + ) + + BeforeEach(func(ctx context.Context) { + deleteBucketClass = &v1alpha1.BucketClass{ + TypeMeta: metav1.TypeMeta{ + Kind: "BucketClass", + APIVersion: "objectstorage.k8s.io/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "delete-bucketclass", + }, + DeletionPolicy: v1alpha1.DeletionPolicyDelete, + DriverName: "ntnx.objectstorage.k8s.io", + } + + failBucketClass = &v1alpha1.BucketClass{ + TypeMeta: metav1.TypeMeta{ + Kind: "BucketClass", + APIVersion: "objectstorage.k8s.io/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "fail-delete-bucketclass", + }, + DeletionPolicy: v1alpha1.DeletionPolicyDelete, + DriverName: "ntnx.objectstorage.k8s.io", + } + + deleteBucketClaim = &v1alpha1.BucketClaim{ + TypeMeta: metav1.TypeMeta{ + Kind: "BucketClaim", + APIVersion: "objectstorage.k8s.io/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "delete-bucketclaim", + Namespace: namespace, + }, + Spec: v1alpha1.BucketClaimSpec{ + BucketClassName: "delete-bucketclass", + Protocols: []v1alpha1.Protocol{ + v1alpha1.ProtocolS3, + }, + }, + } + + failBucketClaim = &v1alpha1.BucketClaim{ + TypeMeta: metav1.TypeMeta{ + Kind: "BucketClaim", + APIVersion: "objectstorage.k8s.io/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "fail-delete-bucketclaim", + Namespace: namespace, + }, + Spec: v1alpha1.BucketClaimSpec{ + BucketClassName: "fail-delete-bucketclass", + Protocols: []v1alpha1.Protocol{ + v1alpha1.ProtocolS3, + }, + }, + } + + retainBucketClass = &v1alpha1.BucketClass{ + TypeMeta: metav1.TypeMeta{ + Kind: "BucketClass", + APIVersion: "objectstorage.k8s.io/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "retain-bucketclass", + }, + DeletionPolicy: v1alpha1.DeletionPolicyRetain, + DriverName: "ntnx.objectstorage.k8s.io", + } + + retainBucketClaim = &v1alpha1.BucketClaim{ + TypeMeta: metav1.TypeMeta{ + Kind: "BucketClaim", + APIVersion: "objectstorage.k8s.io/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "retain-bucketclaim", + Namespace: namespace, + }, + Spec: v1alpha1.BucketClaimSpec{ + BucketClassName: "retain-bucketclass", + Protocols: []v1alpha1.Protocol{ + v1alpha1.ProtocolS3, + }, + }, + } + }) + + When("DeletionPolicy is set to 'Delete'", func() { + It("Successfully deletes the bucket from Objectstore backend", func(ctx context.Context) { + By("Creating a BucketClass resource from 'delete-bucketclass") + _, err := bucketClient.ObjectstorageV1alpha1().BucketClasses().Create(ctx, deleteBucketClass, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(func(ctx context.Context) { + _ = bucketClient.ObjectstorageV1alpha1().BucketClasses().Delete(ctx, deleteBucketClass.Name, metav1.DeleteOptions{}) + }) + + By("Creating a BucketClaim resource from 'delete-bucketclaim'") + deleteBucketClaim, err := bucketClient.ObjectstorageV1alpha1().BucketClaims(deleteBucketClaim.Namespace).Create(ctx, deleteBucketClaim, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + // By("Checking if Bucket CR is created") + deleteBucket, err := helpers.GetBucket(ctx, bucketClient, deleteBucketClaim) + Expect(err).ToNot(HaveOccurred()) + Expect(deleteBucket).ToNot(BeNil()) + + // By("Checking if Bucket references the 'delete-bucketclass' and 'delete-bucketclaim'") + Expect(deleteBucket.Spec.BucketClassName).To(Equal(deleteBucketClass.Name)) + Expect(deleteBucket.Spec.BucketClaim.Name).To(Equal(deleteBucketClaim.Name)) + + // By("Checking if Bucket status is 'bucketReady'") + err = helpers.CheckBucketStatusReady(ctx, bucketClient, deleteBucket.Name) + Expect(err).ToNot(HaveOccurred()) + + // By("Checking if Bucket is created in the Objectstore backend") + err = helpers.CheckBucketExistenceInObjectstore(ctx, s3Client, deleteBucket.Name) + Expect(err).ToNot(HaveOccurred()) + + By("Deleting BucketClaim resource 'delete-bucketClaim") + err = bucketClient.ObjectstorageV1alpha1().BucketClaims(deleteBucketClaim.Namespace).Delete(ctx, deleteBucketClaim.Name, metav1.DeleteOptions{}) + Expect(err).ToNot(HaveOccurred()) + + By("Checking if Bucket is available in the Objectstore backend") + err = helpers.CheckBucketDeletionInObjectstore(ctx, s3Client, deleteBucket.Name) + Expect(err).ToNot(HaveOccurred()) + }) + }) + + When("DeletionPolicy is set to 'Retain'", func() { + It("Does not delete the bucket from Objectstore backend", func(ctx context.Context) { + By("Creating a BucketClass resource from 'retain-bucketclass") + _, err := bucketClient.ObjectstorageV1alpha1().BucketClasses().Create(ctx, retainBucketClass, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(func(ctx context.Context) { + _ = bucketClient.ObjectstorageV1alpha1().BucketClasses().Delete(ctx, retainBucketClass.Name, metav1.DeleteOptions{}) + }) + + By("Creating a BucketClaim resource from 'retain-bucketclaim'") + retainBucketClaim, err := bucketClient.ObjectstorageV1alpha1().BucketClaims(retainBucketClaim.Namespace).Create(ctx, retainBucketClaim, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + // By("Checking if Bucket CR is created") + retainBucket, err := helpers.GetBucket(ctx, bucketClient, retainBucketClaim) + Expect(err).ToNot(HaveOccurred()) + Expect(retainBucket).ToNot(BeNil()) + + // By("Checking if Bucket references the 'retain-bucketclass' and 'retain-bucketclaim'") + Expect(retainBucket.Spec.BucketClassName).To(Equal(retainBucketClass.Name)) + Expect(retainBucket.Spec.BucketClaim.Name).To(Equal(retainBucketClaim.Name)) + + // By("Checking if Bucket status is 'bucketReady'") + err = helpers.CheckBucketStatusReady(ctx, bucketClient, retainBucket.Name) + Expect(err).ToNot(HaveOccurred()) + + // By("Checking if Bucket is created in the Objectstore backend") + // err = s3Client.WaitUntilBucketExists(&s3.HeadBucketInput{Bucket: &retainBucket.Name}) + err = helpers.CheckBucketExistenceInObjectstore(ctx, s3Client, retainBucket.Name) + Expect(err).ToNot(HaveOccurred()) + + By("Deleting BucketClaim resource 'retain-bucketClaim") + err = bucketClient.ObjectstorageV1alpha1().BucketClaims(retainBucketClaim.Namespace).Delete(ctx, retainBucketClaim.Name, metav1.DeleteOptions{}) + Expect(err).ToNot(HaveOccurred()) + + By("Checking if Bucket is available in the Objectstore backend") + err = helpers.CheckBucketExistenceInObjectstore(ctx, s3Client, retainBucket.Name) + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(func(ctx context.Context) { + _, _ = s3Client.DeleteBucket(&s3.DeleteBucketInput{Bucket: &retainBucket.Name}) + _ = helpers.CheckBucketDeletionInObjectstore(ctx, s3Client, retainBucket.Name) + }) + }) + }) + + When("Bucket does not exist", func() { + It("Completes execution without throwing error", func(ctx context.Context) { + By("Creating a BucketClass resource from 'delete-bucketclass") + _, err := bucketClient.ObjectstorageV1alpha1().BucketClasses().Create(ctx, failBucketClass, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(func(ctx context.Context) { + _ = bucketClient.ObjectstorageV1alpha1().BucketClasses().Delete(ctx, failBucketClass.Name, metav1.DeleteOptions{}) + }) + + By("Creating a BucketClaim resource from 'delete-bucketclaim'") + failBucketClaim, err := bucketClient.ObjectstorageV1alpha1().BucketClaims(failBucketClaim.Namespace).Create(ctx, failBucketClaim, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + // By("Checking if Bucket CR is created") + failBucket, err := helpers.GetBucket(ctx, bucketClient, failBucketClaim) + Expect(err).ToNot(HaveOccurred()) + Expect(failBucket).ToNot(BeNil()) + + // By("Checking if Bucket references the 'delete-bucketclass' and 'delete-bucketclaim'") + Expect(failBucket.Spec.BucketClassName).To(Equal(failBucketClass.Name)) + Expect(failBucket.Spec.BucketClaim.Name).To(Equal(failBucketClaim.Name)) + + // By("Checking if Bucket status is 'bucketReady'") + err = helpers.CheckBucketStatusReady(ctx, bucketClient, failBucket.Name) + Expect(err).ToNot(HaveOccurred()) + + // By("Checking if Bucket is created in the Objectstore backend") + err = helpers.CheckBucketExistenceInObjectstore(ctx, s3Client, failBucket.Name) + Expect(err).ToNot(HaveOccurred()) + + By("Deleting the Bucket from Objectstore") + _, err = s3Client.DeleteBucket(&s3.DeleteBucketInput{Bucket: &failBucket.Name}) + Expect(err).ToNot(HaveOccurred()) + + By("Checking if Bucket is available in the Objectstore backend") + err = helpers.CheckBucketDeletionInObjectstore(ctx, s3Client, failBucket.Name) + Expect(err).ToNot(HaveOccurred()) + + By("Deleting BucketClaim resource 'delete-bucketclaim'") + err = bucketClient.ObjectstorageV1alpha1().BucketClaims(failBucketClaim.Namespace).Delete(ctx, failBucketClaim.Name, metav1.DeleteOptions{}) + Expect(err).ToNot(HaveOccurred()) + }) + }) +}) diff --git a/tests/e2e/e2e_suite_test.go b/tests/e2e/e2e_suite_test.go new file mode 100644 index 0000000..98e11c4 --- /dev/null +++ b/tests/e2e/e2e_suite_test.go @@ -0,0 +1,162 @@ +//go:build e2e_test + +package e2e_test + +import ( + "context" + "crypto/tls" + "net/http" + "os" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/s3" + "github.com/nutanix-core/k8s-ntnx-object-cosi/pkg/admin" + helpers "github.com/nutanix-core/k8s-ntnx-object-cosi/tests/e2e/helpers" + appsv1 "k8s.io/api/apps/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/tools/clientcmd" + bucketclientset "sigs.k8s.io/container-object-storage-interface-api/client/clientset/versioned" +) + +var ( + k8sClient *kubernetes.Clientset + bucketClient *bucketclientset.Clientset + s3Client *s3.S3 + iamClient *admin.API + + namespace string + ossEndpoint string + prismEndpoint string + prismUsername string + prismPassword string + accessKey string + secretKey string + nodeIP string +) + +const ( + deploymentName = "objectstorage-provisioner" +) + +func TestE2E(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "E2E Suite") +} + +var _ = BeforeSuite(func(ctx context.Context) { + + By("Extracting environment variables") + + kubeConfig, exists := os.LookupEnv("KUBECONFIG") + Expect(exists).To(BeTrue()) + + namespace, exists = os.LookupEnv("DRIVER_NAMESPACE") + Expect(exists).To(BeTrue()) + + ossEndpoint, exists = os.LookupEnv("OSS_ENDPOINT") + Expect(exists).To(BeTrue()) + + prismEndpoint, exists = os.LookupEnv("PC_ENDPOINT") + Expect(exists).To(BeTrue()) + + prismUsername, exists = os.LookupEnv("PC_USERNAME") + Expect(exists).To(BeTrue()) + + prismPassword, exists = os.LookupEnv("PC_PASSWORD") + Expect(exists).To(BeTrue()) + + accessKey, exists = os.LookupEnv("ACCESS_KEY") + Expect(exists).To(BeTrue()) + + secretKey, exists = os.LookupEnv("SECRET_KEY") + Expect(exists).To(BeTrue()) + + useTriton, _ := os.LookupEnv("USE_TRITON") + if useTriton != "" { + nodeIP, exists = os.LookupEnv("NODE_IP") + Expect(exists).To(BeTrue()) + + ossEndpoint = "http://" + nodeIP + ":30720" + prismEndpoint = "http://" + nodeIP + ":30556" + } + + By("Building kubernetes client") + testConfig, err := clientcmd.BuildConfigFromFlags("", kubeConfig) + Expect(err).ToNot(HaveOccurred()) + k8sClient, err = kubernetes.NewForConfig(testConfig) + Expect(err).ToNot(HaveOccurred()) + + By("Building COSI buckets client") + bucketClient, err = bucketclientset.NewForConfig(testConfig) + Expect(err).ToNot(HaveOccurred()) + + By("Building S3 client") + sess, err := session.NewSession( + aws.NewConfig(). + WithRegion("us-east-1"). + WithCredentials(credentials.NewStaticCredentials(accessKey, secretKey, "")). + WithEndpoint(ossEndpoint). + WithS3ForcePathStyle(true). + WithMaxRetries(5). + WithDisableSSL(true). + WithHTTPClient( + &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + }, + }, + ). + WithLogLevel(aws.LogOff), + ) + Expect(err).ToNot(HaveOccurred()) + Expect(sess).ToNot(BeNil()) + s3Client = s3.New(sess) + Expect(s3Client).ToNot(BeNil()) + + By("Building IAM client") + iamClient = &admin.API{ + Endpoint: ossEndpoint, + AccessKey: accessKey, + SecretKey: secretKey, + PCEndpoint: prismEndpoint, + PCUsername: prismUsername, + PCPassword: prismPassword, + AccountName: "cosi-test", + HTTPClient: &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + }, + }, + } + Expect(iamClient).ToNot(BeNil()) + + By("Checking cluster availability") + value, err := k8sClient.ServerVersion() + Expect(err).ToNot(HaveOccurred()) + Expect(value).ToNot(BeNil()) + + By("Checking COSI namespace") + _, err = k8sClient.CoreV1().Namespaces().Get(ctx, namespace, metav1.GetOptions{}) + Expect(err).ToNot(HaveOccurred()) + + By("Checking COSI installation") + deployment, err := k8sClient.AppsV1().Deployments(namespace).Get(ctx, deploymentName, metav1.GetOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(deployment.Status.Conditions).To(ContainElement(HaveField("Type", Equal(appsv1.DeploymentAvailable)))) + + By("Checking objectstore existence") + err = helpers.VerifyObjectstore(ctx, ossEndpoint, s3Client) + Expect(err).ToNot(HaveOccurred()) + +}) diff --git a/tests/e2e/grant_suite_test.go b/tests/e2e/grant_suite_test.go new file mode 100644 index 0000000..54edd22 --- /dev/null +++ b/tests/e2e/grant_suite_test.go @@ -0,0 +1,322 @@ +//go:build e2e_test + +package e2e_test + +import ( + "context" + "strings" + + "github.com/aws/aws-sdk-go/service/s3" + helpers "github.com/nutanix-core/k8s-ntnx-object-cosi/tests/e2e/helpers" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/aws/aws-sdk-go/aws" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/container-object-storage-interface-api/apis/objectstorage/v1alpha1" +) + +var _ = Describe("Grant Bucket Access", func() { + var ( + grantBucketClass *v1alpha1.BucketClass + grantBucketClaim *v1alpha1.BucketClaim + grantBucketAccessClass *v1alpha1.BucketAccessClass + failBucketClass *v1alpha1.BucketClass + failBucketClaim *v1alpha1.BucketClaim + failBucketAccessClass *v1alpha1.BucketAccessClass + grantBucketAccess *v1alpha1.BucketAccess + failBucketAccess *v1alpha1.BucketAccess + grantBucket *v1alpha1.Bucket + failBucket *v1alpha1.Bucket + ) + + BeforeEach(func(ctx context.Context) { + grantBucketClass = &v1alpha1.BucketClass{ + TypeMeta: metav1.TypeMeta{ + Kind: "BucketClass", + APIVersion: "objectstorage.k8s.io/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "grant-bucketclass", + }, + DriverName: "ntnx.objectstorage.k8s.io", + DeletionPolicy: v1alpha1.DeletionPolicyDelete, + } + + grantBucketClaim = &v1alpha1.BucketClaim{ + TypeMeta: metav1.TypeMeta{ + Kind: "BucketClaim", + APIVersion: "objectstorage.k8s.io/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "grant-bucketclaim", + Namespace: namespace, + }, + Spec: v1alpha1.BucketClaimSpec{ + BucketClassName: "grant-bucketclass", + Protocols: []v1alpha1.Protocol{ + v1alpha1.ProtocolS3, + }, + }, + } + + grantBucketAccessClass = &v1alpha1.BucketAccessClass{ + TypeMeta: metav1.TypeMeta{ + Kind: "BucketAccessClass", + APIVersion: "objectstorage.k8s.io/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "grant-bucketaccessclass", + }, + DriverName: "ntnx.objectstorage.k8s.io", + AuthenticationType: v1alpha1.AuthenticationTypeKey, + } + + failBucketClass = &v1alpha1.BucketClass{ + TypeMeta: metav1.TypeMeta{ + Kind: "BucketClass", + APIVersion: "objectstorage.k8s.io/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "fail-grant-bucketclass", + }, + DriverName: "ntnx.objectstorage.k8s.io", + DeletionPolicy: v1alpha1.DeletionPolicyDelete, + } + + failBucketClaim = &v1alpha1.BucketClaim{ + TypeMeta: metav1.TypeMeta{ + Kind: "BucketClaim", + APIVersion: "objectstorage.k8s.io/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "fail0-grant-bucketclaim", + Namespace: namespace, + }, + Spec: v1alpha1.BucketClaimSpec{ + BucketClassName: "fail-grant-bucketclass", + Protocols: []v1alpha1.Protocol{ + v1alpha1.ProtocolS3, + }, + }, + } + + failBucketAccessClass = &v1alpha1.BucketAccessClass{ + TypeMeta: metav1.TypeMeta{ + Kind: "BucketAccessClass", + APIVersion: "objectstorage.k8s.io/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "fail-grant-bucketaccessclass", + }, + DriverName: "ntnx.objectstorage.k8s.io", + AuthenticationType: v1alpha1.AuthenticationTypeKey, + } + + grantBucketAccess = &v1alpha1.BucketAccess{ + TypeMeta: metav1.TypeMeta{ + Kind: "BucketAccess", + APIVersion: "objectstorage.k8s.io/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "grant-bucketaccess", + Namespace: namespace, + }, + Spec: v1alpha1.BucketAccessSpec{ + BucketAccessClassName: "grant-bucketaccessclass", + BucketClaimName: "grant-bucketclaim", + CredentialsSecretName: "grant-bucketcredentials", + }, + } + + failBucketAccess = &v1alpha1.BucketAccess{ + TypeMeta: metav1.TypeMeta{ + Kind: "BucketAccess", + APIVersion: "objectstorage.k8s.io/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "fail-grant-bucketaccess", + Namespace: namespace, + }, + Spec: v1alpha1.BucketAccessSpec{ + BucketAccessClassName: "fail-grant-bucketaccessclass", + BucketClaimName: "fail-grant-bucketclaim", + CredentialsSecretName: "fail-grant-bucketcredentials", + }, + } + }) + + When("Bucket exists", func() { + It("Successfully creates user and grants bucket access", func(ctx context.Context) { + By("Creating a BucketClass resource from 'grant-bucketclass") + _, err := bucketClient.ObjectstorageV1alpha1().BucketClasses().Create(ctx, grantBucketClass, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(func(ctx context.Context) { + _ = bucketClient.ObjectstorageV1alpha1().BucketClasses().Delete(ctx, grantBucketClass.Name, metav1.DeleteOptions{}) + }) + + By("Creating a BucketClaim resource from 'grant-bucketclaim'") + grantBucketClaim, err = bucketClient.ObjectstorageV1alpha1().BucketClaims(grantBucketClaim.Namespace).Create(ctx, grantBucketClaim, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(func(ctx context.Context) { + _ = bucketClient.ObjectstorageV1alpha1().BucketClaims(grantBucketClaim.Namespace).Delete(ctx, grantBucketClaim.Name, metav1.DeleteOptions{}) + _ = helpers.CheckBucketDeletionInObjectstore(ctx, s3Client, grantBucket.Name) + }) + + // By("Checking if Bucket CR is created") + grantBucket, err = helpers.GetBucket(ctx, bucketClient, grantBucketClaim) + Expect(err).ToNot(HaveOccurred()) + Expect(grantBucket).ToNot(BeNil()) + + // By("Checking if Bucket references the 'delete-bucketclass' and 'delete-bucketclaim'") + Expect(grantBucket.Spec.BucketClassName).To(Equal(grantBucketClass.Name)) + Expect(grantBucket.Spec.BucketClaim.Name).To(Equal(grantBucketClaim.Name)) + + // By("Checking if Bucket status is 'bucketReady'") + err = helpers.CheckBucketStatusReady(ctx, bucketClient, grantBucket.Name) + Expect(err).ToNot(HaveOccurred()) + + // By("Checking if Bucket is created in the Objectstore backend") + err = helpers.CheckBucketExistenceInObjectstore(ctx, s3Client, grantBucket.Name) + Expect(err).ToNot(HaveOccurred()) + + By("Creating a BucketAccessClass resource from 'grant-bucketaccessclass'") + grantBucketAccessClass, err = bucketClient.ObjectstorageV1alpha1().BucketAccessClasses().Create(ctx, grantBucketAccessClass, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(func(ctx context.Context) { + _ = bucketClient.ObjectstorageV1alpha1().BucketAccessClasses().Delete(ctx, grantBucketAccessClass.Name, metav1.DeleteOptions{}) + }) + + By("Creating a BucketAccess resource from 'grant-bucketaccess'") + grantBucketAccess, err := bucketClient.ObjectstorageV1alpha1().BucketAccesses(namespace).Create(ctx, grantBucketAccess, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(func(ctx context.Context) { + _ = bucketClient.ObjectstorageV1alpha1().BucketAccesses(namespace).Delete(ctx, grantBucketAccess.Name, metav1.DeleteOptions{}) + _ = helpers.CheckUserDeletion(ctx, iamClient, grantBucketAccess.Status.AccountID) + }) + + By("Checking if BucketAccess status 'accessGranted' is 'true'") + grantBucketAccess, err = helpers.GetBucketAccess(ctx, bucketClient, grantBucketAccess.Name, grantBucketAccess.Namespace) + Expect(err).ToNot(HaveOccurred()) + Expect(grantBucketAccess).ToNot(BeNil()) + + By("Checking if BucketAccess status 'accountID' is not empty") + Expect(grantBucketAccess.Status.AccountID).ToNot(Or(BeEmpty(), BeNil())) + + By("Checking if a new user is created in Objectstore") + exists, err := helpers.CheckUserExists(ctx, iamClient, grantBucketAccess.Status.AccountID) + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeTrue()) + + By("Checking if secret 'grant-bucketcredentials' is created") + secret, err := k8sClient.CoreV1().Secrets(namespace).Get(ctx, grantBucketAccess.Spec.CredentialsSecretName, metav1.GetOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(secret).ToNot(BeNil()) + Expect(secret.Data).ToNot(Or(BeNil(), BeEmpty())) + + By("Checking S3 ops using secret 'grant-bucketcredentials'") + newS3Client, err := helpers.CreateNewS3ClientFromSecret(ctx, k8sClient, secret.Name, secret.Namespace, ossEndpoint) + Expect(err).ToNot(HaveOccurred()) + Expect(newS3Client).ToNot(BeNil()) + + _, err = newS3Client.PutObject(&s3.PutObjectInput{ + Body: strings.NewReader("Test Object"), + Bucket: aws.String(grantBucket.Name), + Key: aws.String("test.txt"), + }) + Expect(err).ToNot(HaveOccurred()) + + _, err = newS3Client.DeleteObject(&s3.DeleteObjectInput{ + Bucket: aws.String(grantBucket.Name), + Key: aws.String("test.txt"), + }) + Expect(err).ToNot(HaveOccurred()) + }) + }) + + When("Bucket does not exist", func() { + It("Fails to get bucket details and user is not created", func(ctx context.Context) { + By("Creating a BucketClass resource from 'fail-grant-bucketclass") + _, err := bucketClient.ObjectstorageV1alpha1().BucketClasses().Create(ctx, failBucketClass, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(func(ctx context.Context) { + _ = bucketClient.ObjectstorageV1alpha1().BucketClasses().Delete(ctx, failBucketClass.Name, metav1.DeleteOptions{}) + }) + + By("Creating a BucketClaim resource from 'fail-grant-bucketclaim'") + failBucketClaim, err = bucketClient.ObjectstorageV1alpha1().BucketClaims(failBucketClaim.Namespace).Create(ctx, failBucketClaim, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(func(ctx context.Context) { + _ = bucketClient.ObjectstorageV1alpha1().BucketClaims(failBucketClaim.Namespace).Delete(ctx, failBucketClaim.Name, metav1.DeleteOptions{}) + _ = helpers.CheckBucketDeletionInObjectstore(ctx, s3Client, failBucket.Name) + }) + + // By("Checking if Bucket CR is created") + failBucket, err = helpers.GetBucket(ctx, bucketClient, failBucketClaim) + Expect(err).ToNot(HaveOccurred()) + Expect(failBucket).ToNot(BeNil()) + + // By("Checking if Bucket references the 'delete-bucketclass' and 'delete-bucketclaim'") + Expect(failBucket.Spec.BucketClassName).To(Equal(failBucketClass.Name)) + Expect(failBucket.Spec.BucketClaim.Name).To(Equal(failBucketClaim.Name)) + + // By("Checking if Bucket status is 'bucketReady'") + err = helpers.CheckBucketStatusReady(ctx, bucketClient, failBucket.Name) + Expect(err).ToNot(HaveOccurred()) + + // By("Checking if Bucket is created in the Objectstore backend") + err = helpers.CheckBucketExistenceInObjectstore(ctx, s3Client, failBucket.Name) + Expect(err).ToNot(HaveOccurred()) + + By("Creating a BucketAccessClass resource from 'fail-grant-bucketaccessclass'") + failBucketAccessClass, err = bucketClient.ObjectstorageV1alpha1().BucketAccessClasses().Create(ctx, failBucketAccessClass, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(func(ctx context.Context) { + _ = bucketClient.ObjectstorageV1alpha1().BucketAccessClasses().Delete(ctx, failBucketAccessClass.Name, metav1.DeleteOptions{}) + }) + + initNumOfUsers, err := helpers.GetNumOfUsersInObjectstore(ctx, iamClient) + Expect(err).ToNot(HaveOccurred()) + Expect(initNumOfUsers).ToNot(Equal(-1)) + + By("Deleting bucket from objectstore") + _, err = s3Client.DeleteBucket(&s3.DeleteBucketInput{Bucket: &failBucket.Name}) + Expect(err).ToNot(HaveOccurred()) + + By("Checking if bucket is deleted in the objectstore backend") + err = helpers.CheckBucketDeletionInObjectstore(ctx, s3Client, failBucket.Name) + Expect(err).ToNot(HaveOccurred()) + + By("Creating a BucketAccess resource from 'fail-grant-bucketaccess'") + failBucketAccess, err = bucketClient.ObjectstorageV1alpha1().BucketAccesses(namespace).Create(ctx, failBucketAccess, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(func(ctx context.Context) { + _ = bucketClient.ObjectstorageV1alpha1().BucketAccesses(namespace).Delete(ctx, failBucketAccess.Name, metav1.DeleteOptions{}) + _ = helpers.CheckUserDeletion(ctx, iamClient, failBucketAccess.Status.AccountID) + }) + + By("Checking status of bucketAccess resource 'fail-grant-bucketaccess") + err = helpers.CheckBucketAccessNotGranted(ctx, bucketClient, failBucketAccess.Name, failBucketAccess.Namespace) + Expect(err).ToNot(HaveOccurred()) + + By("Checking if user is created in Objectstore") + newNumOfUsers, err := helpers.GetNumOfUsersInObjectstore(ctx, iamClient) + Expect(err).ToNot(HaveOccurred()) + Expect(newNumOfUsers).To(Equal(initNumOfUsers)) + + By("Checking if secret 'fail-grant-bucketcredentials' is created") + _, err = k8sClient.CoreV1().Secrets(namespace).Get(ctx, failBucketAccess.Spec.CredentialsSecretName, metav1.GetOptions{}) + Expect(err).To(MatchError(ContainSubstring("not found"))) + }) + }) + +}) diff --git a/tests/e2e/helpers/helpers.go b/tests/e2e/helpers/helpers.go new file mode 100644 index 0000000..2e52153 --- /dev/null +++ b/tests/e2e/helpers/helpers.go @@ -0,0 +1,392 @@ +package e2e_test_helper + +import ( + "context" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/nutanix-core/k8s-ntnx-object-cosi/pkg/admin" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/s3" + + . "github.com/onsi/gomega" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + cosiapi "sigs.k8s.io/container-object-storage-interface-api/apis" + "sigs.k8s.io/container-object-storage-interface-api/apis/objectstorage/v1alpha1" + bucketclientset "sigs.k8s.io/container-object-storage-interface-api/client/clientset/versioned" +) + +const ( + _attempts = 30 + sleep = 2 * time.Second +) + +func retry(ctx context.Context, f func() error) (err error) { + for i := 0; i < _attempts; i++ { + if i > 0 { + time.Sleep(sleep) + } + err = f() + if err == nil { + return nil + } + } + return err +} + +func VerifyObjectstore(ctx context.Context, ossEndpoint string, s3Client *s3.S3) error { + err := retry(ctx, func() error { + var err error + + _, err = http.Get(ossEndpoint) + if err != nil { + return err + } + _, err = s3Client.ListBuckets(&s3.ListBucketsInput{}) + if err != nil { + return nil + } + + return nil + }) + if err != nil { + return err + } + + return nil +} + +func GetBucketClaim(ctx context.Context, bucketClient *bucketclientset.Clientset, bucketClaim *v1alpha1.BucketClaim) (*v1alpha1.BucketClaim, error) { + err := retry(ctx, func() error { + var err error + + bucketClaim, err = bucketClient.ObjectstorageV1alpha1().BucketClaims(bucketClaim.Namespace).Get(ctx, bucketClaim.Name, metav1.GetOptions{}) + if err != nil { + return err + } + + return nil + }) + if err != nil { + return nil, err + } + + return bucketClaim, nil +} + +func GetBucket(ctx context.Context, bucketClient *bucketclientset.Clientset, bucketClaim *v1alpha1.BucketClaim) (*v1alpha1.Bucket, error) { + var bucket *v1alpha1.Bucket + + err := retry(ctx, func() error { + var err error + + bucketClaim, err = bucketClient.ObjectstorageV1alpha1().BucketClaims(bucketClaim.Namespace).Get(ctx, bucketClaim.Name, metav1.GetOptions{}) + if err != nil { + return err + } + + if bucketClaim.Status.BucketName == "" { + return fmt.Errorf("BucketName is empty") + } + + return nil + }) + if err != nil { + return nil, err + } + + err = retry(ctx, func() error { + var err error + + name := bucketClaim.Status.BucketName + + bucket, err = bucketClient.ObjectstorageV1alpha1().Buckets().Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return err + } + + return nil + }) + if err != nil { + return nil, err + } + + return bucket, nil +} + +func CheckBucketStatusReady(ctx context.Context, bucketClient *bucketclientset.Clientset, bucketName string) error { + err := retry(ctx, func() error { + var err error + + bucket, err := bucketClient.ObjectstorageV1alpha1().Buckets().Get(ctx, bucketName, metav1.GetOptions{}) + if err != nil { + return err + } + + if bucket.Status.BucketReady == false { + return fmt.Errorf("bucket is not ready.") + } + + return nil + }) + if err != nil { + return err + } + + return nil +} + +func CheckBucketStatusNotReady(ctx context.Context, bucketClient *bucketclientset.Clientset, bucketName string) error { + err := retry(ctx, func() error { + var err error + + bucket, err := bucketClient.ObjectstorageV1alpha1().Buckets().Get(ctx, bucketName, metav1.GetOptions{}) + if err != nil { + return err + } + + if bucket.Status.BucketReady == true { + return fmt.Errorf("bucket is ready.") + } + + return nil + }) + if err != nil { + return err + } + + return nil +} + +func CheckBucketExistenceInObjectstore(ctx context.Context, s3Client *s3.S3, bucketName string) error { + err := retry(ctx, func() error { + bucketList, err := s3Client.ListBuckets(&s3.ListBucketsInput{}) + if err != nil { + return err + } + + for _, bucket := range bucketList.Buckets { + if *bucket.Name == bucketName { + return nil + } + } + + return fmt.Errorf("bucket does not exist in objectstore") + }) + if err != nil { + return err + } + + return nil +} + +func CheckBucketDeletionInObjectstore(ctx context.Context, s3Client *s3.S3, bucketName string) error { + err := retry(ctx, func() error { + bucketList, err := s3Client.ListBuckets(&s3.ListBucketsInput{}) + if err != nil { + return err + } + + for _, bucket := range bucketList.Buckets { + if *bucket.Name == bucketName { + return fmt.Errorf("bucket exists in objectstore") + } + } + + return nil + }) + if err != nil { + return err + } + + return nil +} + +func GetBucketAccess(ctx context.Context, bucketClient *bucketclientset.Clientset, bucketAccessName, bucketAccessNamespace string) (*v1alpha1.BucketAccess, error) { + var bucketAccess *v1alpha1.BucketAccess + err := retry(ctx, func() error { + var err error + + bucketAccess, err = bucketClient.ObjectstorageV1alpha1().BucketAccesses(bucketAccessNamespace).Get(ctx, bucketAccessName, metav1.GetOptions{}) + if err != nil { + return err + } + + if !bucketAccess.Status.AccessGranted { + return fmt.Errorf("BucketAccess 'accessGranted' is false") + } + + return nil + }) + if err != nil { + return nil, err + } + + return bucketAccess, nil +} + +func CheckBucketAccessNotGranted(ctx context.Context, bucketClient *bucketclientset.Clientset, bucketAccessName, bucketAccessNamespace string) error { + var bucketAccess *v1alpha1.BucketAccess + err := retry(ctx, func() error { + var err error + + bucketAccess, err = bucketClient.ObjectstorageV1alpha1().BucketAccesses(bucketAccessNamespace).Get(ctx, bucketAccessName, metav1.GetOptions{}) + if err != nil { + return err + } + + if bucketAccess.Status.AccessGranted { + return fmt.Errorf("BucketAccess 'accessGranted' is true") + } + + return nil + }) + if err != nil { + return err + } + + return nil +} + +func CreateNewS3ClientFromSecret(ctx context.Context, k8sClient *kubernetes.Clientset, secretName, secretNamespace, ossEndpoint string) (*s3.S3, error) { + secret, err := k8sClient.CoreV1().Secrets(secretNamespace).Get(ctx, secretName, metav1.GetOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(secret).ToNot(BeNil()) + + var bucketInfo cosiapi.BucketInfo + + err = json.Unmarshal(secret.Data["BucketInfo"], &bucketInfo) + Expect(err).ToNot(HaveOccurred()) + + sess, err := session.NewSession( + aws.NewConfig(). + WithRegion("us-east-1"). + WithCredentials(credentials.NewStaticCredentials(bucketInfo.Spec.S3.AccessKeyID, bucketInfo.Spec.S3.AccessSecretKey, "")). + WithEndpoint(ossEndpoint). + WithS3ForcePathStyle(true). + WithMaxRetries(5). + WithDisableSSL(true). + WithHTTPClient( + &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + }, + }, + ). + WithLogLevel(aws.LogOff), + ) + Expect(err).ToNot(HaveOccurred()) + Expect(sess).ToNot(BeNil()) + + newS3Client := s3.New(sess) + Expect(newS3Client).ToNot(BeNil()) + + return newS3Client, err +} + +func checkUserExistsUtil(ctx context.Context, api *admin.API, uuid string) (bool, error) { + url := api.PCEndpoint + "/oss/iam_proxy/users/" + string(uuid) + request, err := http.NewRequest("GET", url, nil) + if err != nil { + return false, fmt.Errorf("failed to create http request. %w", err) + } + + request.SetBasicAuth(api.PCUsername, api.PCPassword) + resp, err := api.HTTPClient.Do(request) + if err != nil { + return false, fmt.Errorf("failed to send http request. %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == 404 { + return false, nil + } else if resp.StatusCode == 200 { + return true, nil + } else { + return false, fmt.Errorf("non-200 response: %d", resp.StatusCode) + } +} + +func CheckUserExists(ctx context.Context, api *admin.API, uuid string) (bool, error) { + err := retry(ctx, func() error { + var err error + + exists, err := checkUserExistsUtil(ctx, api, uuid) + if err != nil { + return err + } + if exists { + return nil + } + + return fmt.Errorf("user does not exist") + }) + + if err != nil { + return false, err + } + + return true, nil +} + +func CheckUserDeletion(ctx context.Context, api *admin.API, uuid string) error { + err := retry(ctx, func() error { + var err error + + exists, err := checkUserExistsUtil(ctx, api, uuid) + if err != nil { + return err + } + if exists { + return fmt.Errorf("user exists") + } + + return nil + }) + + if err != nil { + return err + } + + return nil +} + +func GetNumOfUsersInObjectstore(ctx context.Context, api *admin.API) (int, error) { + var userResp struct { + Length int `json:"length"` + } + url := api.PCEndpoint + "/oss/iam_proxy/users" + request, err := http.NewRequest("GET", url, nil) + if err != nil { + return -1, fmt.Errorf("failed to create http request. %w", err) + } + + request.SetBasicAuth(api.PCUsername, api.PCPassword) + resp, err := api.HTTPClient.Do(request) + if err != nil { + return -1, fmt.Errorf("failed to send http request. %w", err) + } + defer resp.Body.Close() + decodedResponse, err := io.ReadAll(resp.Body) + if err != nil { + return -1, err + } + + err = json.Unmarshal(decodedResponse, &userResp) + if err != nil { + return -1, err + } + + return userResp.Length, nil +} diff --git a/tests/e2e/revoke_suite_test.go b/tests/e2e/revoke_suite_test.go new file mode 100644 index 0000000..70c390a --- /dev/null +++ b/tests/e2e/revoke_suite_test.go @@ -0,0 +1,290 @@ +//go:build e2e_test + +package e2e_test + +import ( + "context" + + helpers "github.com/nutanix-core/k8s-ntnx-object-cosi/tests/e2e/helpers" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/aws/aws-sdk-go/service/s3" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/container-object-storage-interface-api/apis/objectstorage/v1alpha1" +) + +var _ = Describe("Revoke Bucket Access", func() { + var ( + revokeBucketClass *v1alpha1.BucketClass + failBucketClass *v1alpha1.BucketClass + revokeBucketClaim *v1alpha1.BucketClaim + failBucketClaim *v1alpha1.BucketClaim + revokeBucketAccessClass *v1alpha1.BucketAccessClass + failBucketAccessClass *v1alpha1.BucketAccessClass + revokeBucketAccess *v1alpha1.BucketAccess + failBucketAccess *v1alpha1.BucketAccess + revokeBucket *v1alpha1.Bucket + failBucket *v1alpha1.Bucket + ) + + BeforeEach(func(ctx context.Context) { + revokeBucketClass = &v1alpha1.BucketClass{ + TypeMeta: metav1.TypeMeta{ + Kind: "BucketClass", + APIVersion: "objectstorage.k8s.io/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "revoke-bucketclass", + }, + DriverName: "ntnx.objectstorage.k8s.io", + DeletionPolicy: v1alpha1.DeletionPolicyDelete, + } + + failBucketClass = &v1alpha1.BucketClass{ + TypeMeta: metav1.TypeMeta{ + Kind: "BucketClass", + APIVersion: "objectstorage.k8s.io/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "fail-revoke-bucketclass", + }, + DriverName: "ntnx.objectstorage.k8s.io", + DeletionPolicy: v1alpha1.DeletionPolicyDelete, + } + + revokeBucketClaim = &v1alpha1.BucketClaim{ + TypeMeta: metav1.TypeMeta{ + Kind: "BucketClaim", + APIVersion: "objectstorage.k8s.io/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "revoke-bucketclaim", + Namespace: namespace, + }, + Spec: v1alpha1.BucketClaimSpec{ + BucketClassName: "revoke-bucketclass", + Protocols: []v1alpha1.Protocol{ + v1alpha1.ProtocolS3, + }, + }, + } + + failBucketClaim = &v1alpha1.BucketClaim{ + TypeMeta: metav1.TypeMeta{ + Kind: "BucketClaim", + APIVersion: "objectstorage.k8s.io/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "fail-revoke-bucketclaim", + Namespace: namespace, + }, + Spec: v1alpha1.BucketClaimSpec{ + BucketClassName: "fail-revoke-bucketclass", + Protocols: []v1alpha1.Protocol{ + v1alpha1.ProtocolS3, + }, + }, + } + + revokeBucketAccessClass = &v1alpha1.BucketAccessClass{ + TypeMeta: metav1.TypeMeta{ + Kind: "BucketAccessClass", + APIVersion: "objectstorage.k8s.io/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "revoke-bucketaccessclass", + }, + DriverName: "ntnx.objectstorage.k8s.io", + AuthenticationType: v1alpha1.AuthenticationTypeKey, + } + + failBucketAccessClass = &v1alpha1.BucketAccessClass{ + TypeMeta: metav1.TypeMeta{ + Kind: "BucketAccessClass", + APIVersion: "objectstorage.k8s.io/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "fail-revoke-bucketaccessclass", + }, + DriverName: "ntnx.objectstorage.k8s.io", + AuthenticationType: v1alpha1.AuthenticationTypeKey, + } + + revokeBucketAccess = &v1alpha1.BucketAccess{ + TypeMeta: metav1.TypeMeta{ + Kind: "BucketAccess", + APIVersion: "objectstorage.k8s.io/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "revoke-bucketaccess", + Namespace: namespace, + }, + Spec: v1alpha1.BucketAccessSpec{ + BucketAccessClassName: "revoke-bucketaccessclass", + BucketClaimName: "revoke-bucketclaim", + CredentialsSecretName: "revoke-bucketcredentials", + }, + } + + failBucketAccess = &v1alpha1.BucketAccess{ + TypeMeta: metav1.TypeMeta{ + Kind: "BucketAccess", + APIVersion: "objectstorage.k8s.io/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "fail-revoke-bucketaccess", + Namespace: namespace, + }, + Spec: v1alpha1.BucketAccessSpec{ + BucketAccessClassName: "fail-revoke-bucketaccessclass", + BucketClaimName: "fail-revoke-bucketclaim", + CredentialsSecretName: "fail-revoke-bucketcredentials", + }, + } + }) + + When("User exists", func() { + It("Successfully revokes user access to bucket", func(ctx context.Context) { + By("Creating a BucketClass resource from 'revoke-bucketclass") + _, err := bucketClient.ObjectstorageV1alpha1().BucketClasses().Create(ctx, revokeBucketClass, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(func(ctx context.Context) { + _ = bucketClient.ObjectstorageV1alpha1().BucketClasses().Delete(ctx, revokeBucketClass.Name, metav1.DeleteOptions{}) + }) + + By("Creating a BucketClaim resource from 'revoke-bucketclaim'") + revokeBucketClaim, err = bucketClient.ObjectstorageV1alpha1().BucketClaims(revokeBucketClaim.Namespace).Create(ctx, revokeBucketClaim, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(func(ctx context.Context) { + _ = bucketClient.ObjectstorageV1alpha1().BucketClaims(revokeBucketClaim.Namespace).Delete(ctx, revokeBucketClaim.Name, metav1.DeleteOptions{}) + _ = helpers.CheckBucketDeletionInObjectstore(ctx, s3Client, revokeBucket.Name) + }) + + // By("Checking if Bucket CR is created") + revokeBucket, err = helpers.GetBucket(ctx, bucketClient, revokeBucketClaim) + Expect(err).ToNot(HaveOccurred()) + Expect(revokeBucket).ToNot(BeNil()) + + // By("Checking if Bucket references the 'delete-bucketclass' and 'delete-bucketclaim'") + Expect(revokeBucket.Spec.BucketClassName).To(Equal(revokeBucketClass.Name)) + Expect(revokeBucket.Spec.BucketClaim.Name).To(Equal(revokeBucketClaim.Name)) + + // By("Checking if Bucket status is 'bucketReady'") + err = helpers.CheckBucketStatusReady(ctx, bucketClient, revokeBucket.Name) + Expect(err).ToNot(HaveOccurred()) + + // By("Checking if Bucket is created in the Objectstore backend") + err = helpers.CheckBucketExistenceInObjectstore(ctx, s3Client, revokeBucket.Name) + Expect(err).ToNot(HaveOccurred()) + + By("Creating a BucketAccessClass resource from 'revoke-bucketaccessclass'") + revokeBucketAccessClass, err = bucketClient.ObjectstorageV1alpha1().BucketAccessClasses().Create(ctx, revokeBucketAccessClass, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(func(ctx context.Context) { + _ = bucketClient.ObjectstorageV1alpha1().BucketAccessClasses().Delete(ctx, revokeBucketAccessClass.Name, metav1.DeleteOptions{}) + }) + + By("Creating a BucketAccess resource from 'revoke-bucketaccess'") + revokeBucketAccess, err = bucketClient.ObjectstorageV1alpha1().BucketAccesses(namespace).Create(ctx, revokeBucketAccess, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + // By("Checking if BucketAccess status 'accessGranted' is 'true'") + revokeBucketAccess, err = helpers.GetBucketAccess(ctx, bucketClient, revokeBucketAccess.Name, revokeBucketAccess.Namespace) + Expect(err).ToNot(HaveOccurred()) + Expect(revokeBucketAccess).ToNot(BeNil()) + + // By("Checking if BucketAccess status 'accountID' is not empty") + Expect(revokeBucketAccess.Status.AccountID).ToNot(Or(BeEmpty(), BeNil())) + + // By("Checking if a new user is created in Objectstore") + exists, err := helpers.CheckUserExists(ctx, iamClient, revokeBucketAccess.Status.AccountID) + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeTrue()) + + By("Deleting BucketAccess resource 'revoke-bucketaccess") + err = bucketClient.ObjectstorageV1alpha1().BucketAccesses(namespace).Delete(ctx, revokeBucketAccess.Name, metav1.DeleteOptions{}) + Expect(err).ToNot(HaveOccurred()) + + By("Checking if user created by resource 'revoke-bucketaccess' is deleted") + err = helpers.CheckUserDeletion(ctx, iamClient, revokeBucketAccess.Status.AccountID) + Expect(err).ToNot(HaveOccurred()) + }) + }) + + When("User does not exist", func() { + It("Completes execution without throwing error", func(ctx context.Context) { + By("Creating a BucketClass resource from 'revoke-bucketclass") + _, err := bucketClient.ObjectstorageV1alpha1().BucketClasses().Create(ctx, failBucketClass, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(func(ctx context.Context) { + _ = bucketClient.ObjectstorageV1alpha1().BucketAccessClasses().Delete(ctx, failBucketAccessClass.Name, metav1.DeleteOptions{}) + }) + + By("Creating a BucketClaim resource from 'fail-revoke-bucketclaim'") + failBucketClaim, err = bucketClient.ObjectstorageV1alpha1().BucketClaims(failBucketClaim.Namespace).Create(ctx, failBucketClaim, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(func(ctx context.Context) { + _ = bucketClient.ObjectstorageV1alpha1().BucketClaims(failBucketClaim.Namespace).Delete(ctx, failBucketClaim.Name, metav1.DeleteOptions{}) + _ = helpers.CheckBucketDeletionInObjectstore(ctx, s3Client, failBucket.Name) + }) + + // By("Checking if Bucket CR is created") + failBucket, err = helpers.GetBucket(ctx, bucketClient, failBucketClaim) + Expect(err).ToNot(HaveOccurred()) + Expect(failBucket).ToNot(BeNil()) + + // By("Checking if Bucket references the 'delete-bucketclass' and 'delete-bucketclaim'") + Expect(failBucket.Spec.BucketClassName).To(Equal(failBucketClass.Name)) + Expect(failBucket.Spec.BucketClaim.Name).To(Equal(failBucketClaim.Name)) + + // By("Checking if Bucket status is 'bucketReady'") + err = helpers.CheckBucketStatusReady(ctx, bucketClient, failBucket.Name) + Expect(err).ToNot(HaveOccurred()) + + // By("Checking if Bucket is created in the Objectstore backend") + err = s3Client.WaitUntilBucketExists(&s3.HeadBucketInput{Bucket: &failBucket.Name}) + err = helpers.CheckBucketExistenceInObjectstore(ctx, s3Client, failBucket.Name) + Expect(err).ToNot(HaveOccurred()) + + By("Creating a BucketAccessClass resource from 'revoke-bucketaccessclass'") + failBucketAccessClass, err = bucketClient.ObjectstorageV1alpha1().BucketAccessClasses().Create(ctx, failBucketAccessClass, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(func(ctx context.Context) { + _ = bucketClient.ObjectstorageV1alpha1().BucketAccessClasses().Delete(ctx, failBucketAccessClass.Name, metav1.DeleteOptions{}) + }) + + By("Creating a BucketAccess resource from 'fail-revoke-bucketaccess'") + failBucketAccess, err = bucketClient.ObjectstorageV1alpha1().BucketAccesses(namespace).Create(ctx, failBucketAccess, metav1.CreateOptions{}) + Expect(err).ToNot(HaveOccurred()) + + // By("Checking if BucketAccess status 'accessGranted' is 'true'") + failBucketAccess, err = helpers.GetBucketAccess(ctx, bucketClient, failBucketAccess.Name, failBucketAccess.Namespace) + Expect(err).ToNot(HaveOccurred()) + Expect(failBucketAccess).ToNot(BeNil()) + + // By("Checking if BucketAccess status 'accountID' is not empty") + Expect(failBucketAccess.Status.AccountID).ToNot(Or(BeEmpty(), BeNil())) + + // By("Checking if a new user is created in Objectstore") + exists, err := helpers.CheckUserExists(ctx, iamClient, failBucketAccess.Status.AccountID) + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeTrue()) + + By("Deleting user created by BucketAccess resource 'fail-revoke-bucketaccess'") + err = iamClient.RemoveUser(ctx, failBucketAccess.Status.AccountID) + Expect(err).ToNot(HaveOccurred()) + + By("Deleting BucketAccess resource 'fail-revoke-bucketaccess") + err = bucketClient.ObjectstorageV1alpha1().BucketAccesses(namespace).Delete(ctx, failBucketAccess.Name, metav1.DeleteOptions{}) + Expect(err).ToNot(HaveOccurred()) + }) + }) +}) diff --git a/tests/fakes/mocks.go b/tests/fakes/mocks.go new file mode 100644 index 0000000..bdca24f --- /dev/null +++ b/tests/fakes/mocks.go @@ -0,0 +1,81 @@ +package mocks + +import ( + "context" + "net/http" + + "github.com/nutanix-core/k8s-ntnx-object-cosi/pkg/admin" + "github.com/nutanix-core/k8s-ntnx-object-cosi/pkg/util/s3client" + + "github.com/aws/aws-sdk-go/service/s3" +) + +type MockHTTPClient struct { + DoFunc func(req *http.Request) (*http.Response, error) +} + +func (m MockHTTPClient) Do(req *http.Request) (*http.Response, error) { return m.DoFunc(req) } + +type MockIAM struct { + CreateUserFunc func(ctx context.Context, username string, display_name string) (admin.NutanixUserResp, error) + RemoveUserFunc func(ctx context.Context, uuid string) error + GetAccountNameFunc func() string + GetEndpointFunc func() string +} + +func (m MockIAM) CreateUser(ctx context.Context, username string, display_name string) (admin.NutanixUserResp, error) { + return m.CreateUserFunc(ctx, username, display_name) +} +func (m MockIAM) RemoveUser(ctx context.Context, uuid string) error { + return m.RemoveUserFunc(ctx, uuid) +} +func (m MockIAM) GetAccountName() string { return m.GetAccountNameFunc() } +func (m MockIAM) GetEndpoint() string { return m.GetEndpointFunc() } + +type MockS3Client struct { + CreateBucketFunc func(input *s3.CreateBucketInput) (*s3.CreateBucketOutput, error) + DeleteBucketFunc func(input *s3.DeleteBucketInput) (*s3.DeleteBucketOutput, error) + DeleteObjectFunc func(input *s3.DeleteObjectInput) (*s3.DeleteObjectOutput, error) + PutObjectFunc func(input *s3.PutObjectInput) (*s3.PutObjectOutput, error) + GetObjectFunc func(input *s3.GetObjectInput) (*s3.GetObjectOutput, error) + GetBucketPolicyFunc func(input *s3.GetBucketPolicyInput) (*s3.GetBucketPolicyOutput, error) + PutBucketPolicyFunc func(input *s3.PutBucketPolicyInput) (*s3.PutBucketPolicyOutput, error) +} + +func (m *MockS3Client) CreateBucket(input *s3.CreateBucketInput) (*s3.CreateBucketOutput, error) { + return m.CreateBucketFunc(input) +} +func (m *MockS3Client) DeleteBucket(input *s3.DeleteBucketInput) (*s3.DeleteBucketOutput, error) { + return m.DeleteBucketFunc(input) +} +func (m *MockS3Client) DeleteObject(input *s3.DeleteObjectInput) (*s3.DeleteObjectOutput, error) { + return m.DeleteObjectFunc(input) +} +func (m *MockS3Client) PutObject(input *s3.PutObjectInput) (*s3.PutObjectOutput, error) { + return m.PutObjectFunc(input) +} +func (m *MockS3Client) GetObject(input *s3.GetObjectInput) (*s3.GetObjectOutput, error) { + return m.GetObjectFunc(input) +} +func (m *MockS3Client) GetBucketPolicy(input *s3.GetBucketPolicyInput) (*s3.GetBucketPolicyOutput, error) { + return m.GetBucketPolicyFunc(input) +} +func (m *MockS3Client) PutBucketPolicy(input *s3.PutBucketPolicyInput) (*s3.PutBucketPolicyOutput, error) { + return m.PutBucketPolicyFunc(input) +} + +type MockProvisionerS3 struct { + CreateBucketFunc func(name string) error + DeleteBucketFunc func(name string) (bool, error) + GetBucketPolicyFunc func(bucket string) (*s3client.BucketPolicy, error) + PutBucketPolicyFunc func(bucket string, policy s3client.BucketPolicy) (*s3.PutBucketPolicyOutput, error) +} + +func (m MockProvisionerS3) CreateBucket(name string) error { return m.CreateBucketFunc(name) } +func (m MockProvisionerS3) DeleteBucket(name string) (bool, error) { return m.DeleteBucketFunc(name) } +func (m MockProvisionerS3) GetBucketPolicy(bucket string) (*s3client.BucketPolicy, error) { + return m.GetBucketPolicyFunc(bucket) +} +func (m MockProvisionerS3) PutBucketPolicy(bucket string, policy s3client.BucketPolicy) (*s3.PutBucketPolicyOutput, error) { + return m.PutBucketPolicyFunc(bucket, policy) +} From 504dc02f69043281a2b17d4168f8fecb76a18011 Mon Sep 17 00:00:00 2001 From: "ankush.patanwal" Date: Sun, 4 May 2025 13:09:04 +0000 Subject: [PATCH 2/3] Added example of PC endpoint in values.yaml --- charts/values.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/charts/values.yaml b/charts/values.yaml index 4efc625..6dbc9dd 100644 --- a/charts/values.yaml +++ b/charts/values.yaml @@ -19,7 +19,7 @@ secret: access_key: "" # Admin IAM Secret key to be used for Nutanix Objects. secret_key: "" - # PC Endpoint + # Prism Central endpoint, eg. "https://10.51.149.82:9440" pc_endpoint: "" # PC Credentials. pc_username: "admin" From 6f130ab13b02dabfb3eb1b495e80d306505d0798 Mon Sep 17 00:00:00 2001 From: "ankush.patanwal" Date: Sun, 4 May 2025 19:14:12 +0000 Subject: [PATCH 3/3] Updated github pipeline to include unit tests --- .github/workflows/unit-test.yaml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .github/workflows/unit-test.yaml diff --git a/.github/workflows/unit-test.yaml b/.github/workflows/unit-test.yaml new file mode 100644 index 0000000..07cf6c0 --- /dev/null +++ b/.github/workflows/unit-test.yaml @@ -0,0 +1,29 @@ +name: Unit Test + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + go-version: [1.24.x] + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go ${{ matrix.go-version }} + uses: actions/setup-go@v4 + with: + go-version: ${{ matrix.go-version }} + + - name: Download dependencies + run: go mod download + + - name: Run tests + run: | + go test ./... -v -coverprofile=coverage.out