diff --git a/.github/workflows/create-prerelease.yml b/.github/workflows/create-prerelease.yml index 3bfe314..a488b4a 100644 --- a/.github/workflows/create-prerelease.yml +++ b/.github/workflows/create-prerelease.yml @@ -14,13 +14,15 @@ on: push: branches: - - "VEC-189-tls" # remove before merge into main + - "VEC-210-acl" # remove before merge into main jobs: build-and-release: runs-on: macos-13 steps: - name: "Git checkout" uses: actions/checkout@v3 + with: + fetch-depth: 0 - name: "Install Homebrew" run: /bin/bash -c "NONINTERACTIVE=1 $(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" - name: "Install Dependencies" @@ -111,11 +113,12 @@ jobs: cd ~/work/asvec/asvec/bin/packages COMMIT=$(git rev-parse --short HEAD) VER=$(cat ../../VERSION.md) + RPM_VER=$(echo ${VER} | sed 's/-/_/g') BRANCH=$(git rev-parse --abbrev-ref HEAD) TAG=${VER}-${COMMIT} [ "${ADDCOMMIT}" = "false" ] && TAG=${VER} FULLCOMMIT=$(git rev-parse HEAD) - gh release create -R github.com/aerospike/asvec --notes-file ../../RELEASE.md --prerelease --target ${FULLCOMMIT} --title "Asvec - v${TAG}" ${TAG} asvec-linux-amd64-${VER}.deb asvec-linux-amd64-${VER}.rpm asvec-linux-amd64-${VER}.zip asvec-linux-arm64-${VER}.deb asvec-linux-arm64-${VER}.rpm asvec-linux-arm64-${VER}.zip asvec-macos-${VER}.pkg asvec-macos-amd64-${VER}.zip asvec-macos-arm64-${VER}.zip asvec-windows-amd64-${VER}.zip asvec-windows-arm64-${VER}.zip + gh release create -R github.com/aerospike/asvec --notes-file ../../RELEASE.md --prerelease --target ${FULLCOMMIT} --title "Asvec - v${TAG}" ${TAG} asvec-linux-amd64-${VER}.deb asvec-linux-amd64-${RPM_VER}.rpm asvec-linux-amd64-${VER}.zip asvec-linux-arm64-${VER}.deb asvec-linux-arm64-${RPM_VER}.rpm asvec-linux-arm64-${VER}.zip asvec-macos-${VER}.pkg asvec-macos-amd64-${VER}.zip asvec-macos-arm64-${VER}.zip asvec-windows-amd64-${VER}.zip asvec-windows-arm64-${VER}.zip - name: "Delete previous pre-release" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/readme-check-links.yml b/.github/workflows/link-checker.yml similarity index 100% rename from .github/workflows/readme-check-links.yml rename to .github/workflows/link-checker.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9a82f7a..32df685 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -21,7 +21,13 @@ jobs: FEATURES_CONF : ${{secrets.FEATURES_CONF}} run: | - echo "$FEATURES_CONF" > docker/config/features.conf + echo "$FEATURES_CONF" > docker/vanilla/config/features.conf + echo "$FEATURES_CONF" > docker/tls/config/features.conf + echo "$FEATURES_CONF" > docker/mtls/config/features.conf + echo "$FEATURES_CONF" > docker/auth/config/features.conf + - name: Login to Aerospike Jfrog + run: | + docker login aerospike.jfrog.io --username ${{ secrets.JFROG_USERNAME }} --password ${{ secrets.JFROG_ACCESS_TOKEN }} - name: Run tests run: | make coverage diff --git a/Makefile b/Makefile index 737fb92..f1cde34 100644 --- a/Makefile +++ b/Makefile @@ -157,7 +157,10 @@ clean: OS := $(shell uname -o) CPU := $(shell uname -m) -ver:=$(shell V=$$(git branch --show-current); if [[ $$V == v* ]]; then printf $${V:1} > ./VERSION.md; fi; cat ./VERSION.md) +ver:=$(shell V=$$(git describe --tags --always); printf $${V} > ./VERSION.md; cat ./VERSION.md) +rpm_ver := $(shell echo $(ver) | sed 's/-/_/g') +$(info ver is $(ver) and rpm_ver is $(rpm_ver)) +GO_LDFLAGS="-X 'asvec/cmd.Version=$(ver)' -s -w" define _amddebscript ver=$(cat ./VERSION.md) cat < ./bin/deb/DEBIAN/control @@ -241,14 +244,14 @@ prep: .PHONY: compile_linux_wip_amd64 compile_linux_wip_amd64: - env CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o asvec-linux-amd64-wip + env CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags=$(GO_LDFLAGS) -o asvec-linux-amd64-wip ifneq (, $(shell which upx)) upx asvec-linux-amd64-wip endif .PHONY: compile_linux_wip_arm64 compile_linux_wip_arm64: - env CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags="-s -w" -o asvec-linux-arm64-wip + env CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags=$(GO_LDFLAGS) -o asvec-linux-arm64-wip ifneq (, $(shell which upx)) upx asvec-linux-arm64-wip endif @@ -256,53 +259,53 @@ endif .PHONY: compile_linux_amd64 compile_linux_amd64: printf "package main\n\nimport _ \"embed\"\n\nvar nLinuxBinaryX64 []byte\n\n//go:embed asvec-linux-arm64-wip\nvar nLinuxBinaryArm64 []byte\n" > embed_linux.go - env CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o asvec-linux-amd64 + env CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags=$(GO_LDFLAGS) -o asvec-linux-amd64 mv asvec-linux-amd64 $(BIN_DIR)/ .PHONY: compile_linux_arm64 compile_linux_arm64: printf "package main\n\nimport _ \"embed\"\n\n//go:embed asvec-linux-amd64-wip\nvar nLinuxBinaryX64 []byte\n\nvar nLinuxBinaryArm64 []byte\n" > embed_linux.go - env CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags="-s -w" -o asvec-linux-arm64 + env CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags=$(GO_LDFLAGS) -o asvec-linux-arm64 mv asvec-linux-arm64 $(BIN_DIR)/ .PHONY: compile_darwin compile_darwin: printf "package main\n\nimport _ \"embed\"\n\n//go:embed asvec-linux-amd64-wip\nvar nLinuxBinaryX64 []byte\n\n//go:embed asvec-linux-arm64-wip\nvar nLinuxBinaryArm64 []byte" > embed_darwin.go - env CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o asvec-macos-amd64 - env CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -trimpath -ldflags="-s -w" -o asvec-macos-arm64 + env CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -trimpath -ldflags=$(GO_LDFLAGS) -o asvec-macos-amd64 + env CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -trimpath -ldflags=$(GO_LDFLAGS) -o asvec-macos-arm64 mv asvec-macos-amd64 $(BIN_DIR)/ mv asvec-macos-arm64 $(BIN_DIR)/ .PHONY: compile_darwin_amd64 compile_darwin_amd64: printf "package main\n\nimport _ \"embed\"\n\n//go:embed asvec-linux-amd64-wip\nvar nLinuxBinaryX64 []byte\n\n//go:embed asvec-linux-arm64-wip\nvar nLinuxBinaryArm64 []byte" > embed_darwin.go - env CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o asvec-macos-amd64 + env CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -trimpath -ldflags=$(GO_LDFLAGS) -o asvec-macos-amd64 mv asvec-macos-amd64 $(BIN_DIR)/ .PHONY: compile_darwin_arm64 compile_darwin_arm64: printf "package main\n\nimport _ \"embed\"\n\n//go:embed asvec-linux-amd64-wip\nvar nLinuxBinaryX64 []byte\n\n//go:embed asvec-linux-arm64-wip\nvar nLinuxBinaryArm64 []byte" > embed_darwin.go - env CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -trimpath -ldflags="-s -w" -o asvec-macos-arm64 + env CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -trimpath -ldflags=$(GO_LDFLAGS) -o asvec-macos-arm64 mv asvec-macos-arm64 $(BIN_DIR)/ .PHONY: compile_windows compile_windows: printf "package main\n\nimport _ \"embed\"\n\n//go:embed asvec-linux-amd64-wip\nvar nLinuxBinaryX64 []byte\n\n//go:embed asvec-linux-arm64-wip\nvar nLinuxBinaryArm64 []byte" > embed_windows.go - env CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o asvec-windows-amd64.exe - env CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -trimpath -ldflags="-s -w" -o asvec-windows-arm64.exe + env CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags=$(GO_LDFLAGS) -o asvec-windows-amd64.exe + env CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -trimpath -ldflags=$(GO_LDFLAGS) -o asvec-windows-arm64.exe mv asvec-windows-amd64.exe $(BIN_DIR)/ mv asvec-windows-arm64.exe $(BIN_DIR)/ .PHONY: compile_windows_amd64 compile_windows_amd64: printf "package main\n\nimport _ \"embed\"\n\n//go:embed asvec-linux-amd64-wip\nvar nLinuxBinaryX64 []byte\n\n//go:embed asvec-linux-arm64-wip\nvar nLinuxBinaryArm64 []byte" > embed_windows.go - env CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o asvec-windows-amd64.exe + env CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags=$(GO_LDFLAGS) -o asvec-windows-amd64.exe mv asvec-windows-amd64.exe $(BIN_DIR)/ .PHONY: compile_windows_arm64 compile_windows_arm64: printf "package main\n\nimport _ \"embed\"\n\n//go:embed asvec-linux-amd64-wip\nvar nLinuxBinaryX64 []byte\n\n//go:embed asvec-linux-arm64-wip\nvar nLinuxBinaryArm64 []byte" > embed_windows.go - env CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -trimpath -ldflags="-s -w" -o asvec-windows-arm64.exe + env CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -trimpath -ldflags=$(GO_LDFLAGS) -o asvec-windows-arm64.exe mv asvec-windows-arm64.exe $(BIN_DIR)/ .PHONY: official @@ -388,23 +391,23 @@ pkg-zip: pkg-zip-amd64 pkg-zip-arm64 pkg-rpm-amd64: rm -rf $(BIN_DIR)/asvec-rpm-centos cp -a $(BIN_DIR)/asvecrpm $(BIN_DIR)/asvec-rpm-centos - sed -i.bak "s/VERSIONHERE/${ver}/g" $(BIN_DIR)/asvec-rpm-centos/asvec.spec + sed -i.bak "s/VERSIONHERE/${rpm_ver}/g" $(BIN_DIR)/asvec-rpm-centos/asvec.spec cp $(BIN_DIR)/asvec-linux-amd64 $(BIN_DIR)/asvec-rpm-centos/usr/local/aerospike/bin/asvec rm -f $(BIN_DIR)/asvec-linux-x86_64.rpm bash -ce "cd $(BIN_DIR) && rpmbuild --target=x86_64-redhat-linux --buildroot \$$(pwd)/asvec-rpm-centos -bb asvec-rpm-centos/asvec.spec" - rm -f $(BIN_DIR)/packages/asvec-linux-amd64-${ver}.rpm - mv $(BIN_DIR)/asvec-linux-x86_64.rpm $(BIN_DIR)/packages/asvec-linux-amd64-${ver}.rpm + rm -f $(BIN_DIR)/packages/asvec-linux-amd64-${rpm_ver}.rpm + mv $(BIN_DIR)/asvec-linux-x86_64.rpm $(BIN_DIR)/packages/asvec-linux-amd64-${rpm_ver}.rpm .PHONY: pkg-rpm-arm64 pkg-rpm-arm64: rm -rf $(BIN_DIR)/asvec-rpm-centos cp -a $(BIN_DIR)/asvecrpm $(BIN_DIR)/asvec-rpm-centos - sed -i.bak "s/VERSIONHERE/${ver}/g" $(BIN_DIR)/asvec-rpm-centos/asvec.spec + sed -i.bak "s/VERSIONHERE/${rpm_ver}/g" $(BIN_DIR)/asvec-rpm-centos/asvec.spec cp $(BIN_DIR)/asvec-linux-arm64 $(BIN_DIR)/asvec-rpm-centos/usr/local/aerospike/bin/asvec rm -f $(BIN_DIR)/asvec-linux-arm64.rpm bash -ce "cd $(BIN_DIR) && rpmbuild --target=arm64-redhat-linux --buildroot \$$(pwd)/asvec-rpm-centos -bb asvec-rpm-centos/asvec.spec" - rm -f $(BIN_DIR)/packages/asvec-linux-arm64-${ver}.rpm - mv $(BIN_DIR)/asvec-linux-arm64.rpm $(BIN_DIR)/packages/asvec-linux-arm64-${ver}.rpm + rm -f $(BIN_DIR)/packages/asvec-linux-arm64-${rpm_ver}.rpm + mv $(BIN_DIR)/asvec-linux-arm64.rpm $(BIN_DIR)/packages/asvec-linux-arm64-${rpm_ver}.rpm .PHONY: pkg-rpm pkg-rpm: pkg-rpm-amd64 pkg-rpm-arm64 diff --git a/README.md b/README.md index 082b2e6..c521619 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ efficiency and productivity for users getting started with vector search. Ensure you have an AVS instance up and running for `asvec` to connect to. Checkout out the [AVS documentation](https://aerospike.com/docs/vector) for -options on getting started. +instructions on getting started. ### Installation diff --git a/cmd/constants.go b/cmd/constants.go new file mode 100644 index 0000000..d5f230b --- /dev/null +++ b/cmd/constants.go @@ -0,0 +1,5 @@ +package cmd + +const ( + HelpTxtSetupEnv = "export ASVEC_HOST=:5000 ASVEC_USER= ASVEC_PASSWORD=" +) diff --git a/cmd/create.go b/cmd/create.go deleted file mode 100644 index aee73bc..0000000 --- a/cmd/create.go +++ /dev/null @@ -1,23 +0,0 @@ -/* -Copyright © 2024 NAME HERE -*/ -package cmd - -import ( - "github.com/spf13/cobra" -) - -// createCmd represents the create command -var createCmd = &cobra.Command{ - Use: "create", - Short: "A parent command for creating resources", - Long: `A parent command for creating resources. It currently only supports creating indexes. - For example: - export ASVEC_HOST=:5000 - asvec create index -i myindex -n test -s testset -f vector-field -d 256 -m COSINE - `, -} - -func init() { - rootCmd.AddCommand(createCmd) -} diff --git a/cmd/drop.go b/cmd/drop.go deleted file mode 100644 index a5648d9..0000000 --- a/cmd/drop.go +++ /dev/null @@ -1,23 +0,0 @@ -/* -Copyright © 2024 NAME HERE -*/ -package cmd - -import ( - "github.com/spf13/cobra" -) - -// dropCmd represents the drop command -var dropCmd = &cobra.Command{ - Use: "drop", - Short: "A parent command for dropping resources", - Long: `A parent command for dropping resources. It currently only supports dropping indexes. - For example: - export ASVEC_HOST=:5000 - asvec drop index -i myindex -n test - `, -} - -func init() { - rootCmd.AddCommand(dropCmd) -} diff --git a/cmd/dropIndex.go b/cmd/dropIndex.go deleted file mode 100644 index d0c6306..0000000 --- a/cmd/dropIndex.go +++ /dev/null @@ -1,118 +0,0 @@ -/* -Copyright © 2024 NAME HERE -*/ -package cmd - -import ( - "asvec/cmd/flags" - "context" - "fmt" - "log/slog" - "time" - - commonFlags "github.com/aerospike/tools-common-go/flags" - "github.com/spf13/cobra" - "github.com/spf13/pflag" - "github.com/spf13/viper" -) - -//nolint:govet // Padding not a concern for a CLI -var dropIndexFlags = &struct { - clientFlags flags.ClientFlags - namespace string - sets []string - indexName string - timeout time.Duration -}{ - clientFlags: *flags.NewClientFlags(), -} - -func newDropIndexFlagSet() *pflag.FlagSet { - flagSet := &pflag.FlagSet{} - flagSet.StringVarP(&dropIndexFlags.namespace, flags.Namespace, "n", "", commonFlags.DefaultWrapHelpString("The namespace for the index.")) //nolint:lll // For readability - flagSet.StringSliceVarP(&dropIndexFlags.sets, flags.Sets, "s", nil, commonFlags.DefaultWrapHelpString("The sets for the index.")) //nolint:lll // For readability - flagSet.StringVarP(&dropIndexFlags.indexName, flags.IndexName, "i", "", commonFlags.DefaultWrapHelpString("The name of the index.")) //nolint:lll // For readability - flagSet.DurationVar(&dropIndexFlags.timeout, flags.Timeout, time.Second*5, commonFlags.DefaultWrapHelpString("The distance metric for the index.")) //nolint:lll // For readability - flagSet.AddFlagSet(dropIndexFlags.clientFlags.NewClientFlagSet()) - - return flagSet -} - -var dropIndexRequiredFlags = []string{ - flags.Namespace, - flags.IndexName, -} - -// dropIndexCmd represents the dropIndex command -func newDropIndexCommand() *cobra.Command { - return &cobra.Command{ - Use: "index", - Short: "A command for dropping indexes", - Long: `A command for dropping indexes. Deleting an index will free up - storage but will also disable vector search on your data. - - For example: - export ASVEC_HOST=:5000 - asvec drop index -i myindex -n test - `, - PreRunE: func(_ *cobra.Command, _ []string) error { - if viper.IsSet(flags.Seeds) && viper.IsSet(flags.Host) { - return fmt.Errorf("only --%s or --%s allowed", flags.Seeds, flags.Host) - } - - return nil - }, - RunE: func(_ *cobra.Command, _ []string) error { - logger.Debug("parsed flags", - append(dropIndexFlags.clientFlags.NewSLogAttr(), - slog.String(flags.Namespace, dropIndexFlags.namespace), - slog.Any(flags.Sets, dropIndexFlags.sets), - slog.String(flags.IndexName, dropIndexFlags.indexName), - slog.Duration(flags.Timeout, dropIndexFlags.timeout), - )..., - ) - - adminClient, err := createClientFromFlags(&dropIndexFlags.clientFlags, dropIndexFlags.timeout) - if err != nil { - return err - } - defer adminClient.Close() - - if !confirm(fmt.Sprintf( - "Are you sure you want to drop the index %s on field %s?", - nsAndSetString( - createIndexFlags.namespace, - createIndexFlags.sets, - ), - createIndexFlags.vectorField, - )) { - return nil - } - - ctx, cancel := context.WithTimeout(context.Background(), dropIndexFlags.timeout) - defer cancel() - - err = adminClient.IndexDrop(ctx, dropIndexFlags.namespace, dropIndexFlags.indexName) - if err != nil { - logger.Error("unable to drop index", slog.Any("error", err)) - return err - } - - view.Printf("Successfully dropped index %s.%s", dropIndexFlags.namespace, dropIndexFlags.indexName) - return nil - }, - } -} - -func init() { - dropIndexCmd := newDropIndexCommand() - dropCmd.AddCommand(dropIndexCmd) - dropIndexCmd.Flags().AddFlagSet(newDropIndexFlagSet()) - - for _, flag := range dropIndexRequiredFlags { - err := dropIndexCmd.MarkFlagRequired(flag) - if err != nil { - panic(err) - } - } -} diff --git a/cmd/flags/client.go b/cmd/flags/client.go index 45d410e..a906e75 100644 --- a/cmd/flags/client.go +++ b/cmd/flags/client.go @@ -3,17 +3,20 @@ package flags import ( "fmt" "log/slog" + "time" commonFlags "github.com/aerospike/tools-common-go/flags" "github.com/spf13/pflag" ) +//nolint:govet // Padding not a concern for a CLI type ClientFlags struct { Host *HostPortFlag Seeds *SeedsSliceFlag ListenerName StringOptionalFlag User StringOptionalFlag Password commonFlags.PasswordFlag + Timeout time.Duration TLSFlags } @@ -27,26 +30,33 @@ func NewClientFlags() *ClientFlags { func (cf *ClientFlags) NewClientFlagSet() *pflag.FlagSet { flagSet := &pflag.FlagSet{} - flagSet.VarP(cf.Host, Host, "h", commonFlags.DefaultWrapHelpString(fmt.Sprintf("The AVS host to connect to. If cluster discovery is needed use --%s", Seeds))) //nolint:lll // For readability - flagSet.Var(cf.Seeds, Seeds, commonFlags.DefaultWrapHelpString(fmt.Sprintf("The AVS seeds to use for cluster discovery. If no cluster discovery is needed (i.e. load-balancer) then use --%s", Host))) //nolint:lll // For readability - flagSet.VarP(&cf.ListenerName, ListenerName, "l", commonFlags.DefaultWrapHelpString("The listener to ask the AVS server for as configured in the AVS server. Likely required for cloud deployments.")) //nolint:lll // For readability - flagSet.VarP(&cf.User, User, "U", commonFlags.DefaultWrapHelpString("The AVS user to authenticate with.")) //nolint:lll // For readability - flagSet.VarP(&cf.Password, Password, "P", commonFlags.DefaultWrapHelpString("The AVS password for the specified user.")) //nolint:lll // For readability + flagSet.VarP(cf.Host, Host, "h", commonFlags.DefaultWrapHelpString(fmt.Sprintf("The AVS host to connect to. If cluster discovery is needed use --%s. Additionally can be set using the environment variable ASVEC_HOST.", Seeds))) //nolint:lll // For readability + flagSet.Var(cf.Seeds, Seeds, commonFlags.DefaultWrapHelpString(fmt.Sprintf("The AVS seeds to use for cluster discovery. If no cluster discovery is needed (i.e. load-balancer) then use --%s. Additionally can be set using the environment variable ASVEC_SEEDS.", Host))) //nolint:lll // For readability + flagSet.VarP(&cf.ListenerName, ListenerName, "l", commonFlags.DefaultWrapHelpString("The listener to ask the AVS server for as configured in the AVS server. Likely required for cloud deployments.")) //nolint:lll // For readability + flagSet.VarP(&cf.User, AuthUser, "U", commonFlags.DefaultWrapHelpString("The AVS user to authenticate with. Additionally can be set using the environment variable ASVEC_USER")) //nolint:lll // For readability + flagSet.VarP(&cf.Password, AuthPassword, "P", commonFlags.DefaultWrapHelpString("The AVS password for the specified user. By default the environment variable ASVEC_PASSWORD will be checked. Other environment variables can also be used as well as different formats (i.e. base64)")) //nolint:lll // For readability + flagSet.DurationVar(&cf.Timeout, Timeout, time.Second*5, commonFlags.DefaultWrapHelpString("The timeout to use for each request to AVS")) //nolint:lll // For readability flagSet.AddFlagSet(cf.NewTLSFlagSet(commonFlags.DefaultWrapHelpString)) return flagSet } func (cf *ClientFlags) NewSLogAttr() []any { + logPass := "" + if cf.Password.String() != "" { + logPass = "*" + } + return []any{slog.String(Host, cf.Host.String()), slog.String(Seeds, cf.Seeds.String()), slog.String(ListenerName, cf.ListenerName.String()), - slog.String(User, cf.User.String()), - slog.String(Password, cf.Password.String()), + slog.String(AuthUser, cf.User.String()), + slog.String(AuthPassword, logPass), slog.Bool(TLSCaFile, cf.TLSRootCAFile != nil), slog.Bool(TLSCaPath, cf.TLSRootCAPath != nil), slog.Bool(TLSCertFile, cf.TLSCertFile != nil), slog.Bool(TLSKeyFile, cf.TLSKeyFile != nil), slog.Bool(TLSKeyFilePass, cf.TLSKeyFilePass != nil), + slog.Duration(Timeout, cf.Timeout), } } diff --git a/cmd/flags/constants.go b/cmd/flags/constants.go index 888f67b..17e09d9 100644 --- a/cmd/flags/constants.go +++ b/cmd/flags/constants.go @@ -5,10 +5,14 @@ const ( Seeds = "seeds" Host = "host" ListenerName = "listener-name" - User = "user" - Password = "password" + AuthUser = "user" + AuthPassword = "password" + Name = "name" + NewPassword = "new-password" + Roles = "roles" Namespace = "namespace" Sets = "sets" + Yes = "yes" IndexName = "index-name" VectorField = "vector-field" Dimension = "dimension" @@ -29,5 +33,5 @@ const ( TLSCaPath = "tls-capath" TLSCertFile = "tls-certfile" TLSKeyFile = "tls-keyfile" - TLSKeyFilePass = "tls-keyfile-password" + TLSKeyFilePass = "tls-keyfile-password" //nolint:gosec // Not a credential ) diff --git a/cmd/flags/tls.go b/cmd/flags/tls.go index 837f2c7..ea571dd 100644 --- a/cmd/flags/tls.go +++ b/cmd/flags/tls.go @@ -8,6 +8,7 @@ import ( "github.com/spf13/pflag" ) +//nolint:govet // Padding not a concern for a CLI type TLSFlags struct { TLSProtocols commonFlags.TLSProtocolsFlag TLSRootCAFile commonFlags.CertFlag diff --git a/cmd/index.go b/cmd/index.go new file mode 100644 index 0000000..c8b6287 --- /dev/null +++ b/cmd/index.go @@ -0,0 +1,21 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +// indexCmd represents the create command +var indexCmd = &cobra.Command{ + Use: "index", + Short: "A parent command viewing, creating, and removing indexes.", + Long: `A parent command viewing, creating, and removing indexes. + +For example: + + asvec index --help + `, +} + +func init() { + rootCmd.AddCommand(indexCmd) +} diff --git a/cmd/createIndex.go b/cmd/indexCreate.go similarity index 53% rename from cmd/createIndex.go rename to cmd/indexCreate.go index d178b20..f087df0 100644 --- a/cmd/createIndex.go +++ b/cmd/indexCreate.go @@ -1,6 +1,3 @@ -/* -Copyright © 2024 NAME HERE -*/ package cmd import ( @@ -9,18 +6,17 @@ import ( "fmt" "log/slog" "strings" - "time" "github.com/aerospike/avs-client-go/protos" commonFlags "github.com/aerospike/tools-common-go/flags" "github.com/spf13/cobra" "github.com/spf13/pflag" - "github.com/spf13/viper" ) //nolint:govet // Padding not a concern for a CLI -var createIndexFlags = &struct { +var indexCreateFlags = &struct { clientFlags flags.ClientFlags + yes bool namespace string sets []string indexName string @@ -36,7 +32,6 @@ var createIndexFlags = &struct { hnswBatchMaxRecords flags.Uint32OptionalFlag hnswBatchInterval flags.Uint32OptionalFlag hnswBatchEnabled flags.BoolOptionalFlag - timeout time.Duration }{ clientFlags: *flags.NewClientFlags(), storageNamespace: flags.StringOptionalFlag{}, @@ -49,30 +44,30 @@ var createIndexFlags = &struct { hnswBatchEnabled: flags.BoolOptionalFlag{}, } -func newCreateIndexFlagSet() *pflag.FlagSet { - flagSet := &pflag.FlagSet{} //nolint:lll // For readability - flagSet.StringVarP(&createIndexFlags.namespace, flags.Namespace, "n", "", commonFlags.DefaultWrapHelpString("The namespace for the index.")) //nolint:lll // For readability - flagSet.StringSliceVarP(&createIndexFlags.sets, flags.Sets, "s", nil, commonFlags.DefaultWrapHelpString("The sets for the index.")) //nolint:lll // For readability - flagSet.StringVarP(&createIndexFlags.indexName, flags.IndexName, "i", "", commonFlags.DefaultWrapHelpString("The name of the index.")) //nolint:lll // For readability - flagSet.StringVarP(&createIndexFlags.vectorField, flags.VectorField, "f", "", commonFlags.DefaultWrapHelpString("The name of the vector field.")) //nolint:lll // For readability - flagSet.Uint32VarP(&createIndexFlags.dimensions, flags.Dimension, "d", 0, commonFlags.DefaultWrapHelpString("The dimension of the vector field.")) //nolint:lll // For readability - flagSet.VarP(&createIndexFlags.distanceMetric, flags.DistanceMetric, "m", commonFlags.DefaultWrapHelpString(fmt.Sprintf("The distance metric for the index. Valid values: %s", strings.Join(flags.DistanceMetricEnum(), ", ")))) //nolint:lll // For readability - flagSet.StringToStringVar(&createIndexFlags.indexMeta, flags.IndexMeta, nil, commonFlags.DefaultWrapHelpString("The distance metric for the index.")) //nolint:lll // For readability - flagSet.DurationVar(&createIndexFlags.timeout, flags.Timeout, time.Second*5, commonFlags.DefaultWrapHelpString("The distance metric for the index.")) //nolint:lll // For readability - flagSet.Var(&createIndexFlags.storageNamespace, flags.StorageNamespace, commonFlags.DefaultWrapHelpString("Optional storage namespace where the index is stored. Defaults to the index namespace.")) //nolint:lll // For readability //nolint:lll // For readability - flagSet.Var(&createIndexFlags.storageSet, flags.StorageSet, commonFlags.DefaultWrapHelpString("Optional storage set where the index is stored. Defaults to the index name.")) //nolint:lll // For readability //nolint:lll // For readability - flagSet.Var(&createIndexFlags.hnswMaxEdges, flags.MaxEdges, commonFlags.DefaultWrapHelpString("Maximum number bi-directional links per HNSW vertex. Greater values of 'm' in general provide better recall for data with high dimensionality, while lower values work well for data with lower dimensionality. The storage space required for the index increases proportionally with 'm'")) //nolint:lll // For readability - flagSet.Var(&createIndexFlags.hnswConstructionEf, flags.ConstructionEf, commonFlags.DefaultWrapHelpString("The number of candidate nearest neighbors shortlisted during index creation. Larger values provide better recall at the cost of longer index update times. The default is 100.")) //nolint:lll // For readability - flagSet.Var(&createIndexFlags.hnswEf, flags.Ef, commonFlags.DefaultWrapHelpString("The default number of candidate nearest neighbors shortlisted during search. Larger values provide better recall at the cost of longer search times. The default is 100.")) //nolint:lll // For readability - flagSet.Var(&createIndexFlags.hnswBatchMaxRecords, flags.BatchMaxRecords, commonFlags.DefaultWrapHelpString("Maximum number of records to fit in a batch. The default value is 10000.")) //nolint:lll // For readability - flagSet.Var(&createIndexFlags.hnswBatchInterval, flags.BatchInterval, commonFlags.DefaultWrapHelpString("The maximum amount of time in milliseconds to wait before finalizing a batch. The default value is 10000.")) //nolint:lll // For readability - flagSet.Var(&createIndexFlags.hnswBatchEnabled, flags.BatchEnabled, commonFlags.DefaultWrapHelpString("Enables batching for index updates. Default is true meaning batching is enabled.")) //nolint:lll // For readability - flagSet.AddFlagSet(createIndexFlags.clientFlags.NewClientFlagSet()) +func newIndexCreateFlagSet() *pflag.FlagSet { + flagSet := &pflag.FlagSet{} + flagSet.BoolVarP(&indexCreateFlags.yes, flags.Yes, "y", false, commonFlags.DefaultWrapHelpString("When true do not prompt for confirmation.")) //nolint:lll // For readability + flagSet.StringVarP(&indexCreateFlags.namespace, flags.Namespace, "n", "", commonFlags.DefaultWrapHelpString("The namespace for the index.")) //nolint:lll // For readability + flagSet.StringSliceVarP(&indexCreateFlags.sets, flags.Sets, "s", nil, commonFlags.DefaultWrapHelpString("The sets for the index.")) //nolint:lll // For readability + flagSet.StringVarP(&indexCreateFlags.indexName, flags.IndexName, "i", "", commonFlags.DefaultWrapHelpString("The name of the index.")) //nolint:lll // For readability + flagSet.StringVarP(&indexCreateFlags.vectorField, flags.VectorField, "f", "", commonFlags.DefaultWrapHelpString("The name of the vector field.")) //nolint:lll // For readability + flagSet.Uint32VarP(&indexCreateFlags.dimensions, flags.Dimension, "d", 0, commonFlags.DefaultWrapHelpString("The dimension of the vector field.")) //nolint:lll // For readability + flagSet.VarP(&indexCreateFlags.distanceMetric, flags.DistanceMetric, "m", commonFlags.DefaultWrapHelpString(fmt.Sprintf("The distance metric for the index. Valid values: %s", strings.Join(flags.DistanceMetricEnum(), ", ")))) //nolint:lll // For readability + flagSet.StringToStringVar(&indexCreateFlags.indexMeta, flags.IndexMeta, nil, commonFlags.DefaultWrapHelpString("The distance metric for the index.")) //nolint:lll // For readability //nolint:lll // For readability + flagSet.Var(&indexCreateFlags.storageNamespace, flags.StorageNamespace, commonFlags.DefaultWrapHelpString("Optional storage namespace where the index is stored. Defaults to the index namespace.")) //nolint:lll // For readability //nolint:lll // For readability + flagSet.Var(&indexCreateFlags.storageSet, flags.StorageSet, commonFlags.DefaultWrapHelpString("Optional storage set where the index is stored. Defaults to the index name.")) //nolint:lll // For readability //nolint:lll // For readability + flagSet.Var(&indexCreateFlags.hnswMaxEdges, flags.MaxEdges, commonFlags.DefaultWrapHelpString("Maximum number bi-directional links per HNSW vertex. Greater values of 'm' in general provide better recall for data with high dimensionality, while lower values work well for data with lower dimensionality. The storage space required for the index increases proportionally with 'm'.")) //nolint:lll // For readability + flagSet.Var(&indexCreateFlags.hnswConstructionEf, flags.ConstructionEf, commonFlags.DefaultWrapHelpString("The number of candidate nearest neighbors shortlisted during index creation. Larger values provide better recall at the cost of longer index update times. The default is 100.")) //nolint:lll // For readability + flagSet.Var(&indexCreateFlags.hnswEf, flags.Ef, commonFlags.DefaultWrapHelpString("The default number of candidate nearest neighbors shortlisted during search. Larger values provide better recall at the cost of longer search times. The default is 100.")) //nolint:lll // For readability + flagSet.Var(&indexCreateFlags.hnswBatchMaxRecords, flags.BatchMaxRecords, commonFlags.DefaultWrapHelpString("Maximum number of records to fit in a batch. The default value is 10000.")) //nolint:lll // For readability + flagSet.Var(&indexCreateFlags.hnswBatchInterval, flags.BatchInterval, commonFlags.DefaultWrapHelpString("The maximum amount of time in milliseconds to wait before finalizing a batch. The default value is 10000.")) //nolint:lll // For readability + flagSet.Var(&indexCreateFlags.hnswBatchEnabled, flags.BatchEnabled, commonFlags.DefaultWrapHelpString("Enables batching for index updates. Default is true meaning batching is enabled.")) //nolint:lll // For readability + flagSet.AddFlagSet(indexCreateFlags.clientFlags.NewClientFlagSet()) return flagSet } -var createIndexRequiredFlags = []string{ +var indexCreateRequiredFlags = []string{ flags.Namespace, flags.IndexName, flags.VectorField, @@ -81,51 +76,48 @@ var createIndexRequiredFlags = []string{ } // createIndexCmd represents the createIndex command -func newCreateIndexCmd() *cobra.Command { +func newIndexCreateCmd() *cobra.Command { return &cobra.Command{ - Use: "index", + Use: "create", Short: "A command for creating indexes", - Long: `A command for creating indexes. An index is required to enable vector - search on your data. The index tells AVS where your data is located, - what your vectors look like, and how vectors should be compared to each other. - Optionally, you can tweak where your index is stored and how the HNSW algorithm - behaves. For more information see: https://aerospike.com/docs/vector - - For example: - export ASVEC_HOST=:5000 - asvec create index -i myindex -n test -s testset -d 256 -m COSINE --vector-field vector \ - --storage-namespace test --hnsw-batch-enabled false - `, + Long: fmt.Sprintf(`A command for creating indexes. An index is required to enable vector +search on your data. The index tells AVS where your data is located, +what your vectors look like, and how vectors should be compared to each other. +Optionally, you can tweak where your index is stored and how the HNSW algorithm +behaves. For more information see: https://aerospike.com/docs/vector + +For example: + +%s +asvec index create -i myindex -n test -s testset -d 256 -m COSINE --%s vector \ + --%s test --%s false + `, HelpTxtSetupEnv, flags.VectorField, flags.StorageNamespace, flags.BatchEnabled), PreRunE: func(_ *cobra.Command, _ []string) error { - if viper.IsSet(flags.Seeds) && viper.IsSet(flags.Host) { - return fmt.Errorf("only --%s or --%s allowed", flags.Seeds, flags.Host) - } - - return nil + return checkSeedsAndHost() }, RunE: func(_ *cobra.Command, _ []string) error { logger.Debug("parsed flags", - append(createIndexFlags.clientFlags.NewSLogAttr(), - slog.String(flags.Namespace, createIndexFlags.namespace), - slog.Any(flags.Sets, createIndexFlags.sets), - slog.String(flags.IndexName, createIndexFlags.indexName), - slog.String(flags.VectorField, createIndexFlags.vectorField), - slog.Uint64(flags.Dimension, uint64(createIndexFlags.dimensions)), - slog.Any(flags.IndexMeta, createIndexFlags.indexMeta), - slog.String(flags.DistanceMetric, createIndexFlags.distanceMetric.String()), - slog.Duration(flags.Timeout, createIndexFlags.timeout), - slog.Any(flags.StorageNamespace, createIndexFlags.storageNamespace.String()), - slog.Any(flags.StorageSet, createIndexFlags.storageSet.String()), - slog.Any(flags.MaxEdges, createIndexFlags.hnswMaxEdges.String()), - slog.Any(flags.Ef, createIndexFlags.hnswEf), - slog.Any(flags.ConstructionEf, createIndexFlags.hnswConstructionEf.String()), - slog.Any(flags.BatchMaxRecords, createIndexFlags.hnswBatchMaxRecords.String()), - slog.Any(flags.BatchInterval, createIndexFlags.hnswBatchInterval.String()), - slog.Any(flags.BatchEnabled, createIndexFlags.hnswBatchEnabled.String()), + append(indexCreateFlags.clientFlags.NewSLogAttr(), + slog.Bool(flags.Yes, indexCreateFlags.yes), + slog.String(flags.Namespace, indexCreateFlags.namespace), + slog.Any(flags.Sets, indexCreateFlags.sets), + slog.String(flags.IndexName, indexCreateFlags.indexName), + slog.String(flags.VectorField, indexCreateFlags.vectorField), + slog.Uint64(flags.Dimension, uint64(indexCreateFlags.dimensions)), + slog.Any(flags.IndexMeta, indexCreateFlags.indexMeta), + slog.String(flags.DistanceMetric, indexCreateFlags.distanceMetric.String()), + slog.Any(flags.StorageNamespace, indexCreateFlags.storageNamespace.String()), + slog.Any(flags.StorageSet, indexCreateFlags.storageSet.String()), + slog.Any(flags.MaxEdges, indexCreateFlags.hnswMaxEdges.String()), + slog.Any(flags.Ef, indexCreateFlags.hnswEf), + slog.Any(flags.ConstructionEf, indexCreateFlags.hnswConstructionEf.String()), + slog.Any(flags.BatchMaxRecords, indexCreateFlags.hnswBatchMaxRecords.String()), + slog.Any(flags.BatchInterval, indexCreateFlags.hnswBatchInterval.String()), + slog.Any(flags.BatchEnabled, indexCreateFlags.hnswBatchEnabled.String()), )..., ) - adminClient, err := createClientFromFlags(&createIndexFlags.clientFlags, createIndexFlags.timeout) + adminClient, err := createClientFromFlags(&indexCreateFlags.clientFlags) if err != nil { return err } @@ -133,51 +125,51 @@ func newCreateIndexCmd() *cobra.Command { // Inverted to make it easier to understand var hnswBatchDisabled *bool - if createIndexFlags.hnswBatchEnabled.Val != nil { - bd := !(*createIndexFlags.hnswBatchEnabled.Val) + if indexCreateFlags.hnswBatchEnabled.Val != nil { + bd := !(*indexCreateFlags.hnswBatchEnabled.Val) hnswBatchDisabled = &bd } indexStorage := &protos.IndexStorage{ - Namespace: createIndexFlags.storageNamespace.Val, - Set: createIndexFlags.storageSet.Val, + Namespace: indexCreateFlags.storageNamespace.Val, + Set: indexCreateFlags.storageSet.Val, } hnswParams := &protos.HnswParams{ - M: createIndexFlags.hnswMaxEdges.Val, - Ef: createIndexFlags.hnswEf.Val, - EfConstruction: createIndexFlags.hnswConstructionEf.Val, + M: indexCreateFlags.hnswMaxEdges.Val, + Ef: indexCreateFlags.hnswEf.Val, + EfConstruction: indexCreateFlags.hnswConstructionEf.Val, BatchingParams: &protos.HnswBatchingParams{ - MaxRecords: createIndexFlags.hnswBatchMaxRecords.Val, - Interval: createIndexFlags.hnswBatchInterval.Val, + MaxRecords: indexCreateFlags.hnswBatchMaxRecords.Val, + Interval: indexCreateFlags.hnswBatchInterval.Val, Disabled: hnswBatchDisabled, }, } - if !confirm(fmt.Sprintf( + if !indexCreateFlags.yes && !confirm(fmt.Sprintf( "Are you sure you want to create the index %s field %s?", nsAndSetString( - createIndexFlags.namespace, - createIndexFlags.sets, + indexCreateFlags.namespace, + indexCreateFlags.sets, ), - createIndexFlags.vectorField, + indexCreateFlags.vectorField, )) { return nil } - ctx, cancel := context.WithTimeout(context.Background(), createIndexFlags.timeout) + ctx, cancel := context.WithTimeout(context.Background(), indexCreateFlags.clientFlags.Timeout) defer cancel() err = adminClient.IndexCreate( ctx, - createIndexFlags.namespace, - createIndexFlags.sets, - createIndexFlags.indexName, - createIndexFlags.vectorField, - createIndexFlags.dimensions, - protos.VectorDistanceMetric(protos.VectorDistanceMetric_value[createIndexFlags.distanceMetric.String()]), + indexCreateFlags.namespace, + indexCreateFlags.sets, + indexCreateFlags.indexName, + indexCreateFlags.vectorField, + indexCreateFlags.dimensions, + protos.VectorDistanceMetric(protos.VectorDistanceMetric_value[indexCreateFlags.distanceMetric.String()]), hnswParams, - createIndexFlags.indexMeta, + indexCreateFlags.indexMeta, indexStorage, ) if err != nil { @@ -185,23 +177,23 @@ func newCreateIndexCmd() *cobra.Command { return err } - view.Printf("Successfully created index %s.%s", createIndexFlags.namespace, createIndexFlags.indexName) + view.Printf("Successfully created index %s.%s", indexCreateFlags.namespace, indexCreateFlags.indexName) return nil }, } } func init() { - createIndexCmd := newCreateIndexCmd() - createCmd.AddCommand(createIndexCmd) + createIndexCmd := newIndexCreateCmd() + indexCmd.AddCommand(createIndexCmd) // TODO: Add custom template for usage to take into account terminal width // Ex: https://github.com/sigstore/cosign/pull/3011/files - flagSet := newCreateIndexFlagSet() + flagSet := newIndexCreateFlagSet() createIndexCmd.Flags().AddFlagSet(flagSet) - for _, flag := range createIndexRequiredFlags { + for _, flag := range indexCreateRequiredFlags { err := createIndexCmd.MarkFlagRequired(flag) if err != nil { panic(err) diff --git a/cmd/indexDrop.go b/cmd/indexDrop.go new file mode 100644 index 0000000..1575ef9 --- /dev/null +++ b/cmd/indexDrop.go @@ -0,0 +1,110 @@ +package cmd + +import ( + "asvec/cmd/flags" + "context" + "fmt" + "log/slog" + + commonFlags "github.com/aerospike/tools-common-go/flags" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +//nolint:govet // Padding not a concern for a CLI +var indexDropFlags = &struct { + clientFlags flags.ClientFlags + yes bool + namespace string + sets []string + indexName string +}{ + clientFlags: *flags.NewClientFlags(), +} + +func newIndexDropFlagSet() *pflag.FlagSet { + flagSet := &pflag.FlagSet{} + flagSet.BoolVarP(&indexDropFlags.yes, flags.Yes, "y", false, commonFlags.DefaultWrapHelpString("When true do not prompt for confirmation.")) //nolint:lll // For readability + flagSet.StringVarP(&indexDropFlags.namespace, flags.Namespace, "n", "", commonFlags.DefaultWrapHelpString("The namespace for the index.")) //nolint:lll // For readability + flagSet.StringSliceVarP(&indexDropFlags.sets, flags.Sets, "s", nil, commonFlags.DefaultWrapHelpString("The sets for the index.")) //nolint:lll // For readability + flagSet.StringVarP(&indexDropFlags.indexName, flags.IndexName, "i", "", commonFlags.DefaultWrapHelpString("The name of the index.")) //nolint:lll // For readability + flagSet.AddFlagSet(indexDropFlags.clientFlags.NewClientFlagSet()) + + return flagSet +} + +var indexDropRequiredFlags = []string{ + flags.Namespace, + flags.IndexName, +} + +// dropIndexCmd represents the dropIndex command +func newIndexDropCommand() *cobra.Command { + return &cobra.Command{ + Use: "drop", + Short: "A command for dropping indexes", + Long: fmt.Sprintf(`A command for dropping indexes. Deleting an index will free up +storage but will also disable vector search on your data. + +For example: + +%s +asvec index drop -i myindex -n test + `, HelpTxtSetupEnv), + PreRunE: func(_ *cobra.Command, _ []string) error { + return checkSeedsAndHost() + }, + RunE: func(_ *cobra.Command, _ []string) error { + logger.Debug("parsed flags", + append(indexDropFlags.clientFlags.NewSLogAttr(), + slog.Bool(flags.Yes, indexDropFlags.yes), + slog.String(flags.Namespace, indexDropFlags.namespace), + slog.Any(flags.Sets, indexDropFlags.sets), + slog.String(flags.IndexName, indexDropFlags.indexName), + )..., + ) + + adminClient, err := createClientFromFlags(&indexDropFlags.clientFlags) + if err != nil { + return err + } + defer adminClient.Close() + + if !indexDropFlags.yes && !confirm(fmt.Sprintf( + "Are you sure you want to drop the index %s on field %s?", + nsAndSetString( + indexCreateFlags.namespace, + indexCreateFlags.sets, + ), + indexCreateFlags.vectorField, + )) { + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), indexDropFlags.clientFlags.Timeout) + defer cancel() + + err = adminClient.IndexDrop(ctx, indexDropFlags.namespace, indexDropFlags.indexName) + if err != nil { + logger.Error("unable to drop index", slog.Any("error", err)) + return err + } + + view.Printf("Successfully dropped index %s.%s", indexDropFlags.namespace, indexDropFlags.indexName) + return nil + }, + } +} + +func init() { + indexDropCmd := newIndexDropCommand() + indexCmd.AddCommand(indexDropCmd) + indexDropCmd.Flags().AddFlagSet(newIndexDropFlagSet()) + + for _, flag := range indexDropRequiredFlags { + err := indexDropCmd.MarkFlagRequired(flag) + if err != nil { + panic(err) + } + } +} diff --git a/cmd/listIndex.go b/cmd/indexList.go similarity index 51% rename from cmd/listIndex.go rename to cmd/indexList.go index 5bd334e..c62bfe6 100644 --- a/cmd/listIndex.go +++ b/cmd/indexList.go @@ -1,6 +1,3 @@ -/* -Copyright © 2024 NAME HERE -*/ package cmd import ( @@ -9,68 +6,61 @@ import ( "fmt" "log/slog" "sync" - "time" "github.com/aerospike/avs-client-go/protos" commonFlags "github.com/aerospike/tools-common-go/flags" "github.com/spf13/cobra" "github.com/spf13/pflag" - "github.com/spf13/viper" ) -var listIndexFlags = &struct { +var indexListFlags = &struct { clientFlags flags.ClientFlags verbose bool - timeout time.Duration }{ clientFlags: *flags.NewClientFlags(), } -func newListIndexFlagSet() *pflag.FlagSet { +func newIndexListFlagSet() *pflag.FlagSet { flagSet := &pflag.FlagSet{} - flagSet.BoolVarP(&listIndexFlags.verbose, flags.Verbose, "v", false, commonFlags.DefaultWrapHelpString("Print detailed index information.")) //nolint:lll // For readability - flagSet.DurationVar(&listIndexFlags.timeout, flags.Timeout, time.Second*5, commonFlags.DefaultWrapHelpString("The distance metric for the index.")) //nolint:lll // For readability - flagSet.AddFlagSet(listIndexFlags.clientFlags.NewClientFlagSet()) + flagSet.BoolVarP(&indexListFlags.verbose, flags.Verbose, "v", false, commonFlags.DefaultWrapHelpString("Print detailed index information.")) //nolint:lll // For readability + flagSet.AddFlagSet(indexListFlags.clientFlags.NewClientFlagSet()) return flagSet } -var listIndexRequiredFlags = []string{} +var indexListRequiredFlags = []string{} // listIndexCmd represents the listIndex command -func newListIndexCmd() *cobra.Command { +func newIndexListCmd() *cobra.Command { return &cobra.Command{ - Use: "index", - Aliases: []string{"indexes"}, + Use: "ls", + Aliases: []string{"list"}, Short: "A command for listing indexes", - Long: fmt.Sprintf(`A command for displaying useful information about AVS indexes. To display additional - index information use the --%s flag. - For example: - export ASVEC_HOST=:5000 - asvec list index - `, flags.Verbose), - PreRunE: func(_ *cobra.Command, _ []string) error { - if viper.IsSet(flags.Seeds) && viper.IsSet(flags.Host) { - return fmt.Errorf("only --%s or --%s allowed", flags.Seeds, flags.Host) - } + Long: fmt.Sprintf(`A command for listing useful information about AVS indexes. To display additional +index information use the --%s flag. - return nil +For example: + +%s +asvec index ls + `, flags.Verbose, HelpTxtSetupEnv), + PreRunE: func(_ *cobra.Command, _ []string) error { + return checkSeedsAndHost() }, RunE: func(_ *cobra.Command, _ []string) error { logger.Debug("parsed flags", - append(listIndexFlags.clientFlags.NewSLogAttr(), - slog.Bool(flags.Verbose, listIndexFlags.verbose), - slog.Duration(flags.Timeout, listIndexFlags.timeout), + append(indexListFlags.clientFlags.NewSLogAttr(), + slog.Bool(flags.Verbose, indexListFlags.verbose), )..., ) - adminClient, err := createClientFromFlags(&listIndexFlags.clientFlags, listIndexFlags.timeout) + adminClient, err := createClientFromFlags(&indexListFlags.clientFlags) if err != nil { return err } defer adminClient.Close() - ctx, cancel := context.WithTimeout(context.Background(), listIndexFlags.timeout) + ctx, cancel := context.WithTimeout(context.Background(), indexListFlags.clientFlags.Timeout) defer cancel() indexList, err := adminClient.IndexList(ctx) @@ -83,7 +73,7 @@ func newListIndexCmd() *cobra.Command { cancel() - ctx, cancel = context.WithTimeout(context.Background(), listIndexFlags.timeout) + ctx, cancel = context.WithTimeout(context.Background(), indexListFlags.clientFlags.Timeout) defer cancel() wg := sync.WaitGroup{} @@ -110,7 +100,7 @@ func newListIndexCmd() *cobra.Command { logger.Debug("server index list", slog.String("response", indexList.String())) - view.PrintIndexes(indexList, indexStatusList, listIndexFlags.verbose) + view.PrintIndexes(indexList, indexStatusList, indexListFlags.verbose) return nil }, @@ -118,13 +108,13 @@ func newListIndexCmd() *cobra.Command { } func init() { - listIndexCmd := newListIndexCmd() + indexListCmd := newIndexListCmd() - listCmd.AddCommand(listIndexCmd) - listIndexCmd.Flags().AddFlagSet(newListIndexFlagSet()) + indexCmd.AddCommand(indexListCmd) + indexListCmd.Flags().AddFlagSet(newIndexListFlagSet()) - for _, flag := range listIndexRequiredFlags { - err := listIndexCmd.MarkFlagRequired(flag) + for _, flag := range indexListRequiredFlags { + err := indexListCmd.MarkFlagRequired(flag) if err != nil { panic(err) } diff --git a/cmd/list.go b/cmd/list.go deleted file mode 100644 index 7f649df..0000000 --- a/cmd/list.go +++ /dev/null @@ -1,23 +0,0 @@ -/* -Copyright © 2024 NAME HERE -*/ -package cmd - -import ( - "github.com/spf13/cobra" -) - -// listCmd represents the list command -var listCmd = &cobra.Command{ - Use: "list", - Short: "A parent command for listing resources", - Long: `A parent command for listings resources. It currently only supports listing indexes. - For example: - export ASVEC_HOST=:5000 - asvec list index - `, -} - -func init() { - rootCmd.AddCommand(listCmd) -} diff --git a/cmd/role.go b/cmd/role.go new file mode 100644 index 0000000..f2b295d --- /dev/null +++ b/cmd/role.go @@ -0,0 +1,23 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +// userCmd represents the create command +var roleCmd = &cobra.Command{ + Use: "role", + Aliases: []string{"roles"}, + Short: "A parent command for listing roles.", + Long: `A parent command for listing roles. Other sub-commands will be added +in the future. + +For example: + +asvec role --help + `, +} + +func init() { + rootCmd.AddCommand(roleCmd) +} diff --git a/cmd/rolesList.go b/cmd/rolesList.go new file mode 100644 index 0000000..02bae76 --- /dev/null +++ b/cmd/rolesList.go @@ -0,0 +1,86 @@ +package cmd + +import ( + "asvec/cmd/flags" + "context" + "fmt" + "log/slog" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +var rolesListFlags = &struct { + clientFlags flags.ClientFlags +}{ + clientFlags: *flags.NewClientFlags(), +} + +func newRoleListFlagSet() *pflag.FlagSet { + flagSet := &pflag.FlagSet{} + + flagSet.AddFlagSet(rolesListFlags.clientFlags.NewClientFlagSet()) + + return flagSet +} + +var roleListRequiredFlags = []string{} + +// listIndexCmd represents the listIndex command +func newRoleListCmd() *cobra.Command { + return &cobra.Command{ + Use: "ls", + Aliases: []string{"list"}, + Short: "A command for listing roles", + Long: fmt.Sprintf(`A command for listing roles. + +For example: + +%s +asvec role ls + `, HelpTxtSetupEnv), + PreRunE: func(_ *cobra.Command, _ []string) error { + return checkSeedsAndHost() + }, + RunE: func(_ *cobra.Command, _ []string) error { + logger.Debug("parsed flags", + rolesListFlags.clientFlags.NewSLogAttr()..., + ) + + adminClient, err := createClientFromFlags(&rolesListFlags.clientFlags) + if err != nil { + return err + } + defer adminClient.Close() + + ctx, cancel := context.WithTimeout(context.Background(), rolesListFlags.clientFlags.Timeout) + defer cancel() + + userList, err := adminClient.ListRoles(ctx) + if err != nil { + logger.Error("failed to list roles", slog.Any("error", err)) + return err + } + + logger.Debug("server role list", slog.String("response", userList.String())) + + view.PrintRoles(userList) + + return nil + }, + } +} + +func init() { + roleListCmd := newRoleListCmd() + + roleCmd.AddCommand(roleListCmd) + roleListCmd.Flags().AddFlagSet(newRoleListFlagSet()) + + for _, flag := range roleListRequiredFlags { + err := roleListCmd.MarkFlagRequired(flag) + if err != nil { + panic(err) + } + } +} diff --git a/cmd/root.go b/cmd/root.go index 4b38a74..add7105 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,6 +1,3 @@ -/* -Copyright © 2024 NAME HERE -*/ package cmd import ( @@ -20,6 +17,7 @@ import ( var lvl = new(slog.LevelVar) var logger = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: lvl})) var view = NewView(os.Stdout, logger) +var Version = "development" // Overwritten at build time by ld_flags var rootFlags = &struct { logLevel flags.LogLevelFlag @@ -29,10 +27,15 @@ var rootFlags = &struct { var rootCmd = &cobra.Command{ Use: "asvec", Short: "Aerospike Vector Search CLI", - Long: `Welcome to the AVS Deployment Manager CLI Tool! - To start using this tool, please consult the detailed documentation available at https://aerospike.com/docs/vector. - Should you encounter any issues or have questions, feel free to report them by creating a GitHub issue. - Enterprise customers requiring support should contact Aerospike Support directly at https://aerospike.com/support.`, + Long: fmt.Sprintf(`Welcome to the AVS Deployment Manager CLI Tool! +To start using this tool, please consult the detailed documentation available at https://aerospike.com/docs/vector. +Should you encounter any issues or have questions, feel free to report them by creating a GitHub issue. +Enterprise customers requiring support should contact Aerospike Support directly at https://aerospike.com/support. + +For example: +%s +asvec --help + `, HelpTxtSetupEnv), PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { if rootFlags.logLevel.NotSet() { lvl.Set(slog.LevelError + 1) // disable all logging @@ -48,6 +51,10 @@ var rootCmd = &cobra.Command{ handler.Enabled(context.Background(), lvl.Level()) } + if viper.IsSet(flags.Seeds) && viper.IsSet(flags.Host) { + return fmt.Errorf("only --%s or --%s allowed", flags.Seeds, flags.Host) + } + cmd.SilenceUsage = true if err := viper.BindPFlags(cmd.PersistentFlags()); err != nil { @@ -91,10 +98,10 @@ func init() { flags.LogLevel, common.DefaultWrapHelpString(fmt.Sprintf("Log level for additional details and debugging. Valid values: %s", strings.Join(flags.LogLevelEnum(), ", "))), //nolint:lll // For readability ) - common.SetupRoot(rootCmd, "aerospike-vector-search", "0.0.0") // TODO: Handle version + common.SetupRoot(rootCmd, "aerospike-vector-search", Version) // TODO: Handle version viper.SetEnvPrefix("ASVEC") - bindEnvs := []string{flags.Host, flags.Seeds, flags.User, flags.Password} + bindEnvs := []string{flags.Host, flags.Seeds, flags.AuthUser, flags.AuthPassword} // Bind specified flags to ASVEC_* for _, env := range bindEnvs { diff --git a/cmd/user.go b/cmd/user.go new file mode 100644 index 0000000..19a7c1f --- /dev/null +++ b/cmd/user.go @@ -0,0 +1,22 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +// userCmd represents the create command +var userCmd = &cobra.Command{ + Use: "user", + Aliases: []string{"users"}, + Short: "A parent command for viewing and configuring users.", + Long: `A parent command for listing, creating, dropping, and granting roles to users. + +For example: + +asvec user --help + `, +} + +func init() { + rootCmd.AddCommand(userCmd) +} diff --git a/cmd/userCreate.go b/cmd/userCreate.go new file mode 100644 index 0000000..fd235bf --- /dev/null +++ b/cmd/userCreate.go @@ -0,0 +1,114 @@ +package cmd + +import ( + "asvec/cmd/flags" + "context" + "fmt" + "log/slog" + + commonFlags "github.com/aerospike/tools-common-go/flags" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +var userCreateFlags = &struct { + clientFlags flags.ClientFlags + newUsername string + newPassword string + roles []string +}{ + clientFlags: *flags.NewClientFlags(), +} + +func newUserCreateFlagSet() *pflag.FlagSet { + flagSet := &pflag.FlagSet{} + flagSet.AddFlagSet(userCreateFlags.clientFlags.NewClientFlagSet()) + flagSet.StringVar(&userCreateFlags.newUsername, flags.Name, "", commonFlags.DefaultWrapHelpString("The name of the new user.")) //nolint:lll // For readability + flagSet.StringVar(&userCreateFlags.newPassword, flags.NewPassword, "", commonFlags.DefaultWrapHelpString("The password for the new user. If a new password is not provided you you will be prompted to enter a new password.")) //nolint:lll // For readability + flagSet.StringSliceVar(&userCreateFlags.roles, flags.Roles, []string{}, commonFlags.DefaultWrapHelpString("The roles to assign to the new user. To see valid roles run 'asvec role ls'.")) //nolint:lll // For readability + + return flagSet +} + +var userCreateRequiredFlags = []string{ + flags.Name, + flags.Roles, +} + +// createUserCmd represents the createIndex command +func newUserCreateCmd() *cobra.Command { + return &cobra.Command{ + Use: "create", + Short: "A command for creating new users", + Long: fmt.Sprintf(`A command for creating new users. Users are assigned +roles which have certain privileges. Users should have the minimum number of +roles necessary to perform their tasks. + +For example: + +%s +asvec user create --%s foo --%s read-write + `, HelpTxtSetupEnv, flags.Name, flags.Roles), + PreRunE: func(_ *cobra.Command, _ []string) error { + return checkSeedsAndHost() + }, + RunE: func(_ *cobra.Command, _ []string) error { + logger.Debug("parsed flags", + append( + userCreateFlags.clientFlags.NewSLogAttr(), + slog.String(flags.Name, userCreateFlags.newUsername), + slog.Any(flags.Roles, userCreateFlags.roles), + )..., + ) + + adminClient, err := createClientFromFlags(&userCreateFlags.clientFlags) + if err != nil { + return err + } + defer adminClient.Close() + + if userCreateFlags.newPassword == "" { + userCreateFlags.newPassword, err = passwordPrompt("New User Password: ") + if err != nil { + logger.Error("failed to read new password", slog.Any("error", err)) + return err + } + } + + ctx, cancel := context.WithTimeout(context.Background(), userCreateFlags.clientFlags.Timeout) + defer cancel() + + err = adminClient.CreateUser( + ctx, + userCreateFlags.newUsername, + userCreateFlags.newPassword, + userCreateFlags.roles, + ) + if err != nil { + logger.Error("unable to create user", slog.String("user", userCreateFlags.newUsername), slog.Any("error", err)) + return err + } + + view.Printf("Successfully created user %s", userCreateFlags.newUsername) + return nil + }, + } +} + +func init() { + userCreateCmd := newUserCreateCmd() + userCmd.AddCommand(userCreateCmd) + + // TODO: Add custom template for usage to take into account terminal width + // Ex: https://github.com/sigstore/cosign/pull/3011/files + + flagSet := newUserCreateFlagSet() + userCreateCmd.Flags().AddFlagSet(flagSet) + + for _, flag := range userCreateRequiredFlags { + err := userCreateCmd.MarkFlagRequired(flag) + if err != nil { + panic(err) + } + } +} diff --git a/cmd/userDrop.go b/cmd/userDrop.go new file mode 100644 index 0000000..da4e99d --- /dev/null +++ b/cmd/userDrop.go @@ -0,0 +1,96 @@ +package cmd + +import ( + "asvec/cmd/flags" + "context" + "fmt" + "log/slog" + + commonFlags "github.com/aerospike/tools-common-go/flags" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +//nolint:govet // Padding not a concern for a CLI +var userDropFlags = &struct { + clientFlags flags.ClientFlags + dropUser string +}{ + clientFlags: *flags.NewClientFlags(), +} + +func newUserDropFlagSet() *pflag.FlagSet { + flagSet := &pflag.FlagSet{} + flagSet.AddFlagSet(userDropFlags.clientFlags.NewClientFlagSet()) + flagSet.StringVar(&userDropFlags.dropUser, flags.Name, "", commonFlags.DefaultWrapHelpString("The name of the user to drop.")) //nolint:lll // For readability + + return flagSet +} + +var userDropRequiredFlags = []string{ + flags.Name, +} + +func newUserDropCmd() *cobra.Command { + return &cobra.Command{ + Use: "drop", + Short: "A command for dropping users", + Long: fmt.Sprintf(`A command for dropping users. + +For example: + +%s +asvec user drop --%s foo + `, HelpTxtSetupEnv, flags.Name), + PreRunE: func(_ *cobra.Command, _ []string) error { + return checkSeedsAndHost() + }, + RunE: func(_ *cobra.Command, _ []string) error { + logger.Debug("parsed flags", + append( + userDropFlags.clientFlags.NewSLogAttr(), + slog.String(flags.Name, userDropFlags.dropUser), + )..., + ) + + adminClient, err := createClientFromFlags(&userDropFlags.clientFlags) + if err != nil { + return err + } + defer adminClient.Close() + + ctx, cancel := context.WithTimeout(context.Background(), userDropFlags.clientFlags.Timeout) + defer cancel() + + err = adminClient.DropUser( + ctx, + userDropFlags.dropUser, + ) + if err != nil { + logger.Error("unable to create user", slog.String("user", userDropFlags.dropUser), slog.Any("error", err)) + return err + } + + view.Printf("Successfully dropped user %s", userDropFlags.dropUser) + return nil + }, + } +} + +func init() { + userDropCmd := newUserDropCmd() + userCmd.AddCommand(userDropCmd) + + // TODO: Add custom template for usage to take into account terminal width + // Ex: https://github.com/sigstore/cosign/pull/3011/files + + flagSet := newUserDropFlagSet() + userDropCmd.Flags().AddFlagSet(flagSet) + + for _, flag := range userDropRequiredFlags { + err := userDropCmd.MarkFlagRequired(flag) + if err != nil { + panic(err) + } + } +} diff --git a/cmd/userGrant.go b/cmd/userGrant.go new file mode 100644 index 0000000..643b532 --- /dev/null +++ b/cmd/userGrant.go @@ -0,0 +1,111 @@ +//nolint:dupl // Ignore code duplication +package cmd + +import ( + "asvec/cmd/flags" + "context" + "fmt" + "log/slog" + "strings" + + commonFlags "github.com/aerospike/tools-common-go/flags" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +var userGrantFlags = &struct { + clientFlags flags.ClientFlags + grantUser string + roles []string +}{ + clientFlags: *flags.NewClientFlags(), +} + +func newUserGrantFlagSet() *pflag.FlagSet { + flagSet := &pflag.FlagSet{} + flagSet.AddFlagSet(userGrantFlags.clientFlags.NewClientFlagSet()) + flagSet.StringVar(&userGrantFlags.grantUser, flags.Name, "", commonFlags.DefaultWrapHelpString("The existing user to grant new roles")) //nolint:lll // For readability + flagSet.StringSliceVar(&userGrantFlags.roles, flags.Roles, []string{}, commonFlags.DefaultWrapHelpString("The roles to grant the existing user. New roles are added to a users existing roles.")) //nolint:lll // For readability + + return flagSet +} + +var userGrantRequiredFlags = []string{ + flags.Name, + flags.Roles, +} + +func newUserGrantCmd() *cobra.Command { + return &cobra.Command{ + Use: "grant", + Short: "A command for granting roles to an existing users.", + Long: fmt.Sprintf(`A command for granting roles to an existing users. + +For example: + +%s +asvec user grant --%s foo --%s admin + `, HelpTxtSetupEnv, flags.Name, flags.Roles), + PreRunE: func(_ *cobra.Command, _ []string) error { + return checkSeedsAndHost() + }, + RunE: func(_ *cobra.Command, _ []string) error { + logger.Debug("parsed flags", + append( + userGrantFlags.clientFlags.NewSLogAttr(), + slog.String(flags.Name, userGrantFlags.grantUser), + slog.Any(flags.Roles, userGrantFlags.roles), + )..., + ) + + adminClient, err := createClientFromFlags(&userGrantFlags.clientFlags) + if err != nil { + return err + } + defer adminClient.Close() + + ctx, cancel := context.WithTimeout(context.Background(), userGrantFlags.clientFlags.Timeout) + defer cancel() + + err = adminClient.GrantRoles( + ctx, + userGrantFlags.grantUser, + userGrantFlags.roles, + ) + if err != nil { + logger.Error( + "unable to grant user roles", + slog.String("user", userGrantFlags.grantUser), + slog.Any("roles", userGrantFlags.roles), + slog.Any("error", err), + ) + return err + } + + view.Printf( + "Successfully granted user %s roles %s", + userGrantFlags.grantUser, + strings.Join(userGrantFlags.roles, ", "), + ) + return nil + }, + } +} + +func init() { + userGrantCmd := newUserGrantCmd() + userCmd.AddCommand(userGrantCmd) + + // TODO: Add custom template for usage to take into account terminal width + // Ex: https://github.com/sigstore/cosign/pull/3011/files + + flagSet := newUserGrantFlagSet() + userGrantCmd.Flags().AddFlagSet(flagSet) + + for _, flag := range userGrantRequiredFlags { + err := userGrantCmd.MarkFlagRequired(flag) + if err != nil { + panic(err) + } + } +} diff --git a/cmd/userList.go b/cmd/userList.go new file mode 100644 index 0000000..aac118b --- /dev/null +++ b/cmd/userList.go @@ -0,0 +1,86 @@ +package cmd + +import ( + "asvec/cmd/flags" + "context" + "fmt" + "log/slog" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +var userListFlags = &struct { + clientFlags flags.ClientFlags +}{ + clientFlags: *flags.NewClientFlags(), +} + +func newUserListFlagSet() *pflag.FlagSet { + flagSet := &pflag.FlagSet{} + + flagSet.AddFlagSet(userListFlags.clientFlags.NewClientFlagSet()) + + return flagSet +} + +var userListRequiredFlags = []string{} + +// listIndexCmd represents the listIndex command +func newUserListCmd() *cobra.Command { + return &cobra.Command{ + Use: "ls", + Aliases: []string{"list"}, + Short: "A command for listing users", + Long: fmt.Sprintf(`A command for listing useful information about AVS users. +For example: + +%s +asvec user ls + `, HelpTxtSetupEnv), + PreRunE: func(_ *cobra.Command, _ []string) error { + return checkSeedsAndHost() + }, + RunE: func(_ *cobra.Command, _ []string) error { + logger.Debug("parsed flags", + userListFlags.clientFlags.NewSLogAttr()..., + ) + + adminClient, err := createClientFromFlags(&userListFlags.clientFlags) + if err != nil { + return err + } + defer adminClient.Close() + + ctx, cancel := context.WithTimeout(context.Background(), userListFlags.clientFlags.Timeout) + defer cancel() + + userList, err := adminClient.ListUsers(ctx) + if err != nil { + logger.Error("failed to list users", slog.Any("error", err)) + return err + } + + logger.Debug("server user list", slog.String("response", userList.String())) + + view.PrintUsers(userList) + view.Print("Use 'role list' to view available roles") + + return nil + }, + } +} + +func init() { + userListCmd := newUserListCmd() + + userCmd.AddCommand(userListCmd) + userListCmd.Flags().AddFlagSet(newUserListFlagSet()) + + for _, flag := range userListRequiredFlags { + err := userListCmd.MarkFlagRequired(flag) + if err != nil { + panic(err) + } + } +} diff --git a/cmd/userNewPassword.go b/cmd/userNewPassword.go new file mode 100644 index 0000000..d3ed667 --- /dev/null +++ b/cmd/userNewPassword.go @@ -0,0 +1,113 @@ +package cmd + +import ( + "asvec/cmd/flags" + "context" + "fmt" + "log/slog" + + commonFlags "github.com/aerospike/tools-common-go/flags" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +//nolint:govet // Padding not a concern for a CLI +var userNewPassFlags = &struct { + clientFlags flags.ClientFlags + username string + password string +}{ + clientFlags: *flags.NewClientFlags(), +} + +func newUserNewPassFlagSet() *pflag.FlagSet { + flagSet := &pflag.FlagSet{} + flagSet.AddFlagSet(userNewPassFlags.clientFlags.NewClientFlagSet()) + flagSet.StringVar(&userNewPassFlags.username, flags.Name, "", commonFlags.DefaultWrapHelpString("The name of the user.")) //nolint:lll // For readability + flagSet.StringVar(&userNewPassFlags.password, flags.NewPassword, "", commonFlags.DefaultWrapHelpString("The new password for the user. If a new password is not provided you you will be prompted to enter a new password.")) //nolint:lll // For readability + + return flagSet +} + +var userNewPassRequiredFlags = []string{ + flags.Name, +} + +// createUserCmd represents the createIndex command +func newUserNewPasswordCmd() *cobra.Command { + return &cobra.Command{ + Use: "new-password", + Aliases: []string{"new-pass"}, + Short: "Change the password for a user", + Long: fmt.Sprintf(`A command for changing the password for an existing user. + +For example: + +%s +asvec user new-password --%s foo + `, HelpTxtSetupEnv, flags.Name), + PreRunE: func(_ *cobra.Command, _ []string) error { + return checkSeedsAndHost() + }, + RunE: func(_ *cobra.Command, _ []string) error { + logger.Debug("parsed flags", + append( + userNewPassFlags.clientFlags.NewSLogAttr(), + slog.String(flags.Name, userNewPassFlags.username), + )..., + ) + + adminClient, err := createClientFromFlags(&userNewPassFlags.clientFlags) + if err != nil { + return err + } + defer adminClient.Close() + + if userNewPassFlags.password == "" { + userNewPassFlags.password, err = passwordPrompt("New Password: ") + if err != nil { + logger.Error("failed to read new password", slog.Any("error", err)) + return err + } + } + + ctx, cancel := context.WithTimeout(context.Background(), userNewPassFlags.clientFlags.Timeout) + defer cancel() + + err = adminClient.UpdateCredentials( + ctx, + userNewPassFlags.username, + userNewPassFlags.password, + ) + if err != nil { + logger.Error( + "unable to update user credentials", + slog.String("user", userNewPassFlags.username), + slog.Any("error", err), + ) + return err + } + + view.Printf("Successfully updated user %s's credentials", userNewPassFlags.username) + return nil + }, + } +} + +func init() { + userNewPassCmd := newUserNewPasswordCmd() + userCmd.AddCommand(userNewPassCmd) + + // TODO: Add custom template for usage to take into account terminal width + // Ex: https://github.com/sigstore/cosign/pull/3011/files + + flagSet := newUserNewPassFlagSet() + userNewPassCmd.Flags().AddFlagSet(flagSet) + + for _, flag := range userNewPassRequiredFlags { + err := userNewPassCmd.MarkFlagRequired(flag) + if err != nil { + panic(err) + } + } +} diff --git a/cmd/userRevoke.go b/cmd/userRevoke.go new file mode 100644 index 0000000..91615d5 --- /dev/null +++ b/cmd/userRevoke.go @@ -0,0 +1,111 @@ +//nolint:dupl // Ignore code duplication +package cmd + +import ( + "asvec/cmd/flags" + "context" + "fmt" + "log/slog" + "strings" + + commonFlags "github.com/aerospike/tools-common-go/flags" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +var userRevokeFlags = &struct { + clientFlags flags.ClientFlags + revokeUser string + roles []string +}{ + clientFlags: *flags.NewClientFlags(), +} + +func newUserRevokeFlagSet() *pflag.FlagSet { + flagSet := &pflag.FlagSet{} + flagSet.AddFlagSet(userRevokeFlags.clientFlags.NewClientFlagSet()) + flagSet.StringVar(&userRevokeFlags.revokeUser, flags.Name, "", commonFlags.DefaultWrapHelpString("The existing user to revoke new roles.")) //nolint:lll // For readability + flagSet.StringSliceVar(&userRevokeFlags.roles, flags.Roles, []string{}, commonFlags.DefaultWrapHelpString("The roles to revoke from the user. Roles are removed from a user's existing roles.")) //nolint:lll // For readability + + return flagSet +} + +var userRevokeRequiredFlags = []string{ + flags.Name, + flags.Roles, +} + +func newUserRevokeCmd() *cobra.Command { + return &cobra.Command{ + Use: "revoke", + Short: "A command for revoking roles from an existing user.", + Long: fmt.Sprintf(`A command for revoking roles from an existing user. + +For example: + +%s +asvec user revoke --%s foo --%s admin + `, HelpTxtSetupEnv, flags.Name, flags.Roles), + PreRunE: func(_ *cobra.Command, _ []string) error { + return checkSeedsAndHost() + }, + RunE: func(_ *cobra.Command, _ []string) error { + logger.Debug("parsed flags", + append( + userRevokeFlags.clientFlags.NewSLogAttr(), + slog.String(flags.Name, userRevokeFlags.revokeUser), + slog.Any(flags.Roles, userRevokeFlags.roles), + )..., + ) + + adminClient, err := createClientFromFlags(&userRevokeFlags.clientFlags) + if err != nil { + return err + } + defer adminClient.Close() + + ctx, cancel := context.WithTimeout(context.Background(), userRevokeFlags.clientFlags.Timeout) + defer cancel() + + err = adminClient.RevokeRoles( + ctx, + userRevokeFlags.revokeUser, + userRevokeFlags.roles, + ) + if err != nil { + logger.Error( + "unable to revoke user roles", + slog.String("user", userRevokeFlags.revokeUser), + slog.Any("roles", userRevokeFlags.roles), + slog.Any("error", err), + ) + return err + } + + view.Printf( + "Successfully revoked user %s's roles %s", + userRevokeFlags.revokeUser, + strings.Join(userRevokeFlags.roles, ", "), + ) + return nil + }, + } +} + +func init() { + userRevokeCmd := newUserRevokeCmd() + userCmd.AddCommand(userRevokeCmd) + + // TODO: Add custom template for usage to take into account terminal width + // Ex: https://github.com/sigstore/cosign/pull/3011/files + + flagSet := newUserRevokeFlagSet() + userRevokeCmd.Flags().AddFlagSet(flagSet) + + for _, flag := range userRevokeRequiredFlags { + err := userRevokeCmd.MarkFlagRequired(flag) + if err != nil { + panic(err) + } + } +} diff --git a/cmd/utils.go b/cmd/utils.go index 71a062b..7771e93 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -7,17 +7,30 @@ import ( "log/slog" "os" "strings" - "time" "golang.org/x/term" avs "github.com/aerospike/avs-client-go" + "github.com/spf13/viper" ) -func createClientFromFlags(clientFlags *flags.ClientFlags, connectTimeout time.Duration) (*avs.AdminClient, error) { +func passwordPrompt(prompt string) (string, error) { + fmt.Print(prompt) + + bytePassword, err := term.ReadPassword(int(os.Stdin.Fd())) + if err != nil { + return "", err + } + + fmt.Println() + + return string(bytePassword), nil +} + +func createClientFromFlags(clientFlags *flags.ClientFlags) (*avs.AdminClient, error) { hosts, isLoadBalancer := parseBothHostSeedsFlag(clientFlags.Seeds, clientFlags.Host) - ctx, cancel := context.WithTimeout(context.Background(), connectTimeout) + ctx, cancel := context.WithTimeout(context.Background(), clientFlags.Timeout) defer cancel() tlsConfig, err := clientFlags.NewTLSConfig() @@ -27,20 +40,19 @@ func createClientFromFlags(clientFlags *flags.ClientFlags, connectTimeout time.D } var password *string + if clientFlags.User.Val != nil { if len(clientFlags.Password) != 0 { strPass := clientFlags.Password.String() password = &strPass } else { - fmt.Print("Enter Password: ") - bytePassword, err := term.ReadPassword(int(os.Stdin.Fd())) + pass, err := passwordPrompt("Enter Password: ") if err != nil { logger.Error("failed to read password", slog.Any("error", err)) return nil, err } - fmt.Println() // Print a newline after the password input - strPass := string(bytePassword) - password = &strPass + + password = &pass } } @@ -76,11 +88,12 @@ func parseBothHostSeedsFlag(seeds *flags.SeedsSliceFlag, host *flags.HostPortFla func nsAndSetString(namespace string, sets []string) string { var setStr string - if len(sets) == 0 { + switch len(sets) { + case 0: setStr = "*" - } else if len(sets) == 1 { + case 1: setStr = sets[0] - } else { + default: setStr = fmt.Sprintf("%v", sets) } @@ -93,5 +106,13 @@ func confirm(prompt string) bool { fmt.Print(prompt + " (y/n): ") fmt.Scanln(&confirm) - return strings.ToLower(confirm) == "y" + return strings.EqualFold(confirm, "y") +} + +func checkSeedsAndHost() error { + if viper.IsSet(flags.Seeds) && viper.IsSet(flags.Host) { + return fmt.Errorf("only --%s or --%s allowed", flags.Seeds, flags.Host) + } + + return nil } diff --git a/cmd/view.go b/cmd/view.go index c3ccc59..2520555 100644 --- a/cmd/view.go +++ b/cmd/view.go @@ -66,3 +66,31 @@ func (v *View) PrintIndexes( t.Render() } + +func (v *View) getUserListWriter() *writers.UserTableWriter { + return writers.NewUserTableWriter(v.writer, v.logger) +} + +func (v *View) PrintUsers(usersList *protos.ListUsersResponse) { + t := v.getUserListWriter() + + for _, user := range usersList.GetUsers() { + t.AppendUserRow(user) + } + + t.Render() +} + +func (v *View) getRoleListWriter() *writers.RoleTableWriter { + return writers.NewRoleTableWriter(v.writer, v.logger) +} + +func (v *View) PrintRoles(usersList *protos.ListRolesResponse) { + t := v.getRoleListWriter() + + for _, role := range usersList.GetRoles() { + t.AppendRoleRow(role) + } + + t.Render() +} diff --git a/cmd/writers/roleList.go b/cmd/writers/roleList.go new file mode 100644 index 0000000..cc276d8 --- /dev/null +++ b/cmd/writers/roleList.go @@ -0,0 +1,31 @@ +package writers + +import ( + "io" + "log/slog" + + "github.com/aerospike/avs-client-go/protos" + "github.com/jedib0t/go-pretty/v6/table" +) + +type RoleTableWriter struct { + table.Writer + logger *slog.Logger +} + +func NewRoleTableWriter(writer io.Writer, logger *slog.Logger) *RoleTableWriter { + t := RoleTableWriter{NewDefaultWriter(writer), logger} + + t.AppendHeader(table.Row{"Roles"}, rowConfigAutoMerge) + t.SetAutoIndex(true) + t.SortBy([]table.SortBy{ + {Name: "Roles", Mode: table.Asc}, + {Name: "User", Mode: table.Asc}, + }) + + return &t +} + +func (itw *RoleTableWriter) AppendRoleRow(role *protos.Role) { + itw.AppendRow(table.Row{role.GetId()}) +} diff --git a/cmd/writers/userList.go b/cmd/writers/userList.go new file mode 100644 index 0000000..b5c9512 --- /dev/null +++ b/cmd/writers/userList.go @@ -0,0 +1,36 @@ +package writers + +import ( + "io" + "log/slog" + "strings" + + "github.com/aerospike/avs-client-go/protos" + "github.com/jedib0t/go-pretty/v6/table" +) + +type UserTableWriter struct { + table.Writer + logger *slog.Logger +} + +func NewUserTableWriter(writer io.Writer, logger *slog.Logger) *UserTableWriter { + t := UserTableWriter{NewDefaultWriter(writer), logger} + + t.AppendHeader(table.Row{"User", "Roles"}, rowConfigAutoMerge) + + t.SetTitle("Users") + t.SetAutoIndex(true) + t.SortBy([]table.SortBy{ + {Name: "Roles", Mode: table.Asc}, + {Name: "User", Mode: table.Asc}, + }) + + t.Style().Options.SeparateRows = true + + return &t +} + +func (itw *UserTableWriter) AppendUserRow(user *protos.User) { + itw.AppendRow(table.Row{user.GetUsername(), strings.Join(user.GetRoles(), ", ")}) +} diff --git a/docker/auth/docker-compose.yml b/docker/auth/docker-compose.yml index 30a3af1..4d568ad 100644 --- a/docker/auth/docker-compose.yml +++ b/docker/auth/docker-compose.yml @@ -11,7 +11,7 @@ services: - "--config-file" - "/opt/aerospike/etc/aerospike/aerospike.conf" avs: - image: aerospike.jfrog.io/docker/aerospike/aerospike-proximus-private:0.5.0-SNAPSHOT + image: aerospike.jfrog.io/docker/aerospike/aerospike-proximus-private:0.5.1-SNAPSHOT ports: - "10000:10000" networks: diff --git a/docker/mtls/docker-compose.yml b/docker/mtls/docker-compose.yml index 30a3af1..4d568ad 100644 --- a/docker/mtls/docker-compose.yml +++ b/docker/mtls/docker-compose.yml @@ -11,7 +11,7 @@ services: - "--config-file" - "/opt/aerospike/etc/aerospike/aerospike.conf" avs: - image: aerospike.jfrog.io/docker/aerospike/aerospike-proximus-private:0.5.0-SNAPSHOT + image: aerospike.jfrog.io/docker/aerospike/aerospike-proximus-private:0.5.1-SNAPSHOT ports: - "10000:10000" networks: diff --git a/docker/tls/docker-compose.yml b/docker/tls/docker-compose.yml index 30a3af1..4d568ad 100644 --- a/docker/tls/docker-compose.yml +++ b/docker/tls/docker-compose.yml @@ -11,7 +11,7 @@ services: - "--config-file" - "/opt/aerospike/etc/aerospike/aerospike.conf" avs: - image: aerospike.jfrog.io/docker/aerospike/aerospike-proximus-private:0.5.0-SNAPSHOT + image: aerospike.jfrog.io/docker/aerospike/aerospike-proximus-private:0.5.1-SNAPSHOT ports: - "10000:10000" networks: diff --git a/docker/config/aerospike-proximus.yml b/docker/vanilla/config/aerospike-proximus.yml similarity index 94% rename from docker/config/aerospike-proximus.yml rename to docker/vanilla/config/aerospike-proximus.yml index ac73f06..18e64f4 100644 --- a/docker/config/aerospike-proximus.yml +++ b/docker/vanilla/config/aerospike-proximus.yml @@ -9,11 +9,11 @@ cluster: # The Proximus service listening ports, TLS and network interface. service: ports: - 10000: {} - advertised-listeners: - default: - address: 127.0.0.1 - port: 10000 + 10000: + advertised-listeners: + default: + address: 127.0.0.1 + port: 10000 # Management API listening ports, TLS and network interface. manage: diff --git a/docker/config/aerospike.conf b/docker/vanilla/config/aerospike.conf similarity index 100% rename from docker/config/aerospike.conf rename to docker/vanilla/config/aerospike.conf diff --git a/docker/docker-compose.yml b/docker/vanilla/docker-compose.yml similarity index 83% rename from docker/docker-compose.yml rename to docker/vanilla/docker-compose.yml index 9870a90..4d568ad 100644 --- a/docker/docker-compose.yml +++ b/docker/vanilla/docker-compose.yml @@ -11,7 +11,7 @@ services: - "--config-file" - "/opt/aerospike/etc/aerospike/aerospike.conf" avs: - image: aerospike/aerospike-proximus:0.4.0 + image: aerospike.jfrog.io/docker/aerospike/aerospike-proximus-private:0.5.1-SNAPSHOT ports: - "10000:10000" networks: diff --git a/e2e_test.go b/e2e_test.go index 3b636e2..67acc3f 100644 --- a/e2e_test.go +++ b/e2e_test.go @@ -94,14 +94,15 @@ func TestCmdSuite(t *testing.T) { logger.Info("%v", slog.Any("cert", rootCA)) suite.Run(t, &CmdTestSuite{ - composeFile: "docker/docker-compose.yml", // vanilla - suiteFlags: []string{"--log-level debug"}, + composeFile: "docker/vanilla/docker-compose.yml", // vanilla + suiteFlags: []string{"--log-level debug", "--timeout 10s"}, avsIP: "localhost", }) suite.Run(t, &CmdTestSuite{ composeFile: "docker/tls/docker-compose.yml", // tls suiteFlags: []string{ "--log-level debug", + "--timeout 10s", createFlagStr(flags.TLSCaFile, "docker/tls/config/tls/ca.aerospike.com.crt"), }, avsTLSConfig: &tls.Config{ @@ -114,6 +115,7 @@ func TestCmdSuite(t *testing.T) { composeFile: "docker/mtls/docker-compose.yml", // mutual tls suiteFlags: []string{ "--log-level debug", + "--timeout 10s", createFlagStr(flags.TLSCaFile, "docker/mtls/config/tls/ca.aerospike.com.crt"), createFlagStr(flags.TLSCertFile, "docker/mtls/config/tls/localhost.crt"), createFlagStr(flags.TLSKeyFile, "docker/mtls/config/tls/localhost.key"), @@ -128,9 +130,10 @@ func TestCmdSuite(t *testing.T) { composeFile: "docker/auth/docker-compose.yml", // tls + auth (auth requires tls) suiteFlags: []string{ "--log-level debug", + "--timeout 10s", createFlagStr(flags.TLSCaFile, "docker/auth/config/tls/ca.aerospike.com.crt"), - createFlagStr(flags.User, "admin"), - createFlagStr(flags.Password, "admin"), + createFlagStr(flags.AuthUser, "admin"), + createFlagStr(flags.AuthPassword, "admin"), }, avsUser: getStrPtr("admin"), avsPassword: getStrPtr("admin"), @@ -149,6 +152,9 @@ func (suite *CmdTestSuite) SetupSuite() { suite.avsHostPort = avs.NewHostPort(suite.avsIP, suite.avsPort) err := docker_compose_up(suite.composeFile) + + time.Sleep(time.Second * 10) + if err != nil { suite.FailNowf("unable to start docker compose up", "%v", err) } @@ -209,9 +215,15 @@ func (suite *CmdTestSuite) TearDownSuite() { } } -func (suite *CmdTestSuite) runCmd(asvecCmd ...string) ([]string, error) { +// All this does is append the suite flags to args because certain runs (e.g. +// flag parse error tests) should not append this flags +func (suite *CmdTestSuite) runSuiteCmd(asvecCmd ...string) ([]string, error) { suiteFlags := strings.Split(strings.Join(suite.suiteFlags, " "), " ") asvecCmd = append(suiteFlags, asvecCmd...) + return suite.runCmd(asvecCmd...) +} + +func (suite *CmdTestSuite) runCmd(asvecCmd ...string) ([]string, error) { logger.Info("running command", slog.String("cmd", strings.Join(asvecCmd, " "))) cmd := exec.Command(suite.app, asvecCmd...) cmd.Env = []string{"GOCOVERDIR=" + os.Getenv("COVERAGE_DIR")} @@ -241,7 +253,7 @@ func (suite *CmdTestSuite) TestSuccessfulCreateIndexCmd() { "test with storage config", "index1", "test", - fmt.Sprintf("create index --host %s -n test -i index1 -d 256 -m SQUARED_EUCLIDEAN --vector-field vector1 --storage-namespace bar --storage-set testbar --timeout 10s", suite.avsHostPort.String()), + fmt.Sprintf("index create -y --host %s -n test -i index1 -d 256 -m SQUARED_EUCLIDEAN --vector-field vector1 --storage-namespace bar --storage-set testbar s", suite.avsHostPort.String()), NewIndexDefinitionBuilder("index1", "test", 256, protos.VectorDistanceMetric_SQUARED_EUCLIDEAN, "vector1"). WithStorageNamespace("bar"). WithStorageSet("testbar"). @@ -251,7 +263,7 @@ func (suite *CmdTestSuite) TestSuccessfulCreateIndexCmd() { "test with hnsw params and seeds", "index2", "test", - fmt.Sprintf("create index --timeout 10s --seeds %s -n test -i index2 -d 256 -m HAMMING --vector-field vector2 --hnsw-max-edges 10 --hnsw-ef 11 --hnsw-ef-construction 12", suite.avsHostPort.String()), + fmt.Sprintf("index create -y s --seeds %s -n test -i index2 -d 256 -m HAMMING --vector-field vector2 --hnsw-max-edges 10 --hnsw-ef 11 --hnsw-ef-construction 12", suite.avsHostPort.String()), NewIndexDefinitionBuilder("index2", "test", 256, protos.VectorDistanceMetric_HAMMING, "vector2"). WithHnswM(10). WithHnswEf(11). @@ -262,7 +274,7 @@ func (suite *CmdTestSuite) TestSuccessfulCreateIndexCmd() { "test with hnsw batch params", "index3", "test", - fmt.Sprintf("create index --timeout 10s --host %s -n test -i index3 -d 256 -m COSINE --vector-field vector3 --hnsw-batch-enabled false --hnsw-batch-interval 50 --hnsw-batch-max-records 100", suite.avsHostPort.String()), + fmt.Sprintf("index create -y s --host %s -n test -i index3 -d 256 -m COSINE --vector-field vector3 --hnsw-batch-enabled false --hnsw-batch-interval 50 --hnsw-batch-max-records 100", suite.avsHostPort.String()), NewIndexDefinitionBuilder("index3", "test", 256, protos.VectorDistanceMetric_COSINE, "vector3"). WithHnswBatchingMaxRecord(100). WithHnswBatchingInterval(50). @@ -273,11 +285,11 @@ func (suite *CmdTestSuite) TestSuccessfulCreateIndexCmd() { for _, tc := range testCases { suite.Run(tc.name, func() { - lines, err := suite.runCmd(strings.Split(tc.cmd, " ")...) + lines, err := suite.runSuiteCmd(strings.Split(tc.cmd, " ")...) if err != nil { suite.Assert().NoError(err, "error: %s, stdout/err: %s", err, lines) - suite.FailNow("unable to create index") + suite.FailNow("unable to index create") } actual, err := suite.avsClient.IndexGet(context.Background(), tc.indexNamespace, tc.indexName) @@ -292,10 +304,10 @@ func (suite *CmdTestSuite) TestSuccessfulCreateIndexCmd() { } func (suite *CmdTestSuite) TestCreateIndexFailsAlreadyExistsCmd() { - lines, err := suite.runCmd(strings.Split(fmt.Sprintf("create index --host %s -n test -i exists -d 256 -m SQUARED_EUCLIDEAN --vector-field vector1 --storage-namespace bar --storage-set testbar --timeout 10s", suite.avsHostPort.String()), " ")...) + lines, err := suite.runSuiteCmd(strings.Split(fmt.Sprintf("index create -y --host %s -n test -i exists -d 256 -m SQUARED_EUCLIDEAN --vector-field vector1 --storage-namespace bar --storage-set testbar s", suite.avsHostPort.String()), " ")...) suite.Assert().NoError(err, "index should have NOT existed on first call. error: %s, stdout/err: %s", err, lines) - lines, err = suite.runCmd(strings.Split(fmt.Sprintf("create index --host %s -n test -i exists -d 256 -m SQUARED_EUCLIDEAN --vector-field vector1 --storage-namespace bar --storage-set testbar --timeout 10s", suite.avsHostPort.String()), " ")...) + lines, err = suite.runSuiteCmd(strings.Split(fmt.Sprintf("index create -y --host %s -n test -i exists -d 256 -m SQUARED_EUCLIDEAN --vector-field vector1 --storage-namespace bar --storage-set testbar s", suite.avsHostPort.String()), " ")...) suite.Assert().Error(err, "index should HAVE existed on first call. error: %s, stdout/err: %s", err, lines) suite.Assert().Contains(lines[0], "AlreadyExists") @@ -314,7 +326,7 @@ func (suite *CmdTestSuite) TestSuccessfulDropIndexCmd() { "indexdrop1", "test", nil, - fmt.Sprintf("drop index --seeds %s -n test -i indexdrop1 --timeout 10s", suite.avsHostPort.String()), + fmt.Sprintf("index drop -y --seeds %s -n test -i indexdrop1 s", suite.avsHostPort.String()), }, { "test with set", @@ -323,7 +335,7 @@ func (suite *CmdTestSuite) TestSuccessfulDropIndexCmd() { []string{ "testset", }, - fmt.Sprintf("drop index --host %s -n test -s testset -i indexdrop2 --timeout 10s", suite.avsHostPort.String()), + fmt.Sprintf("index drop -y --host %s -n test -s testset -i indexdrop2 s", suite.avsHostPort.String()), }, } @@ -331,16 +343,16 @@ func (suite *CmdTestSuite) TestSuccessfulDropIndexCmd() { suite.Run(tc.name, func() { err := suite.avsClient.IndexCreate(context.Background(), tc.indexNamespace, tc.indexSet, tc.indexName, "vector", 1, protos.VectorDistanceMetric_COSINE, nil, nil, nil) if err != nil { - suite.FailNowf("unable to create index", "%v", err) + suite.FailNowf("unable to index create", "%v", err) } time.Sleep(time.Second * 3) - lines, err := suite.runCmd(strings.Split(tc.cmd, " ")...) + lines, err := suite.runSuiteCmd(strings.Split(tc.cmd, " ")...) suite.Assert().NoError(err, "error: %s, stdout/err: %s", err, lines) if err != nil { - suite.FailNow("unable to drop index") + suite.FailNow("unable to index drop") } _, err = suite.avsClient.IndexGet(context.Background(), tc.indexNamespace, tc.indexName) @@ -355,7 +367,7 @@ func (suite *CmdTestSuite) TestSuccessfulDropIndexCmd() { } func (suite *CmdTestSuite) TestDropIndexFailsDoesNotExistCmd() { - lines, err := suite.runCmd(strings.Split(fmt.Sprintf("drop index --seeds %s -n test -i DNE --timeout 10s", suite.avsHostPort.String()), " ")...) + lines, err := suite.runSuiteCmd(strings.Split(fmt.Sprintf("index drop -y --seeds %s -n test -i DNE s", suite.avsHostPort.String()), " ")...) suite.Assert().Error(err, "index should have NOT existed. stdout/err: %s", lines) suite.Assert().Contains(lines[0], "server error") @@ -392,7 +404,7 @@ func (suite *CmdTestSuite) TestSuccessfulListIndexCmd() { "list", "test", 256, protos.VectorDistanceMetric_COSINE, "vector", ).Build(), }, - fmt.Sprintf("list index -h %s", suite.avsHostPort.String()), + fmt.Sprintf("index list -h %s", suite.avsHostPort.String()), `╭─────────────────────────────────────────────────────────────────────────╮ │ Indexes │ ├───┬──────┬───────────┬────────┬────────────┬─────────────────┬──────────┤ @@ -412,7 +424,7 @@ func (suite *CmdTestSuite) TestSuccessfulListIndexCmd() { "list2", "bar", 256, protos.VectorDistanceMetric_HAMMING, "vector", ).WithSet("barset").Build(), }, - fmt.Sprintf("list index -h %s", suite.avsHostPort.String()), + fmt.Sprintf("index list -h %s", suite.avsHostPort.String()), `╭───────────────────────────────────────────────────────────────────────────────────╮ │ Indexes │ ├───┬───────┬───────────┬────────┬────────┬────────────┬─────────────────┬──────────┤ @@ -434,7 +446,7 @@ func (suite *CmdTestSuite) TestSuccessfulListIndexCmd() { "list2", "bar", 256, protos.VectorDistanceMetric_HAMMING, "vector", ).WithSet("barset").Build(), }, - fmt.Sprintf("list index -h %s --verbose", suite.avsHostPort.String()), + fmt.Sprintf("index list -h %s --verbose", suite.avsHostPort.String()), `╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Indexes │ ├───┬───────┬───────────┬────────┬────────┬────────────┬─────────────────┬──────────┬───────────────────────┬────────────────────────────────┤ @@ -497,83 +509,511 @@ func (suite *CmdTestSuite) TestSuccessfulListIndexCmd() { ) } - lines, err := suite.runCmd(strings.Split(tc.cmd, " ")...) + lines, err := suite.runSuiteCmd(strings.Split(tc.cmd, " ")...) + suite.Assert().NoError(err, "error: %s, stdout/err: %s", err, lines) + + actualTable := removeANSICodes(strings.Join(lines, "\n")) + + suite.Assert().Equal(tc.expectedTable, actualTable) + + }) + } +} + +func (suite *CmdTestSuite) TestSuccessfulUserCreateCmd() { + if suite.avsUser == nil { + suite.T().Skip("authentication is disabled. skipping test") + } + + testCases := []struct { + name string + cmd string + expectedUser *protos.User + }{ + { + "create user with comma sep roles", + fmt.Sprintf("users create --host %s s --name foo1 --new-password foo --roles admin,read-write", suite.avsHostPort.String()), + &protos.User{ + Username: "foo1", + Roles: []string{ + "admin", + "read-write", + }, + }, + }, + { + "create user with comma multiple roles", + fmt.Sprintf("users create --host %s s --name foo2 --new-password foo --roles admin --roles read-write", suite.avsHostPort.String()), + &protos.User{ + Username: "foo2", + Roles: []string{ + "admin", + "read-write", + }, + }, + }, + } + + for _, tc := range testCases { + suite.Run(tc.name, func() { + lines, err := suite.runSuiteCmd(strings.Split(tc.cmd, " ")...) + suite.Assert().NoError(err, "error: %s, stdout/err: %s", err, lines) + + if err != nil { + suite.FailNow("failed") + } + + time.Sleep(time.Second * 1) + + actualUser, err := suite.avsClient.GetUser(context.Background(), tc.expectedUser.Username) + suite.Assert().NoError(err, "error: %s", err) + + suite.Assert().EqualExportedValues(tc.expectedUser, actualUser) + }) + + } +} + +func (suite *CmdTestSuite) TestFailUserCreateCmd() { + if suite.avsUser == nil { + suite.T().Skip("authentication is disabled. skipping test") + } + + testCases := []struct { + name string + cmd string + expectedErr string + }{ + { + "fail to create user with invalid role", + fmt.Sprintf("users create --host %s s --name foo1 --new-password foo --roles invalid", suite.avsHostPort.String()), + "unknown roles [invalid]", + }, + } + + for _, tc := range testCases { + suite.Run(tc.name, func() { + lines, err := suite.runSuiteCmd(strings.Split(tc.cmd, " ")...) + suite.Assert().Error(err, "error: %s, stdout/err: %s", err, lines) + suite.Assert().Contains(lines[0], tc.expectedErr) + }) + + } +} + +func (suite *CmdTestSuite) TestSuccessfulUserDropCmd() { + if suite.avsUser == nil { + suite.T().Skip("authentication is disabled. skipping test") + } + + testCases := []struct { + name string + user string + cmd string + }{ + { + "drop user", + "drop0", + fmt.Sprintf("users drop --host %s s --name drop0", suite.avsHostPort.String()), + }, + } + + for _, tc := range testCases { + suite.Run(tc.name, func() { + err := suite.avsClient.CreateUser(context.Background(), tc.user, tc.user, []string{"admin"}) + suite.Assert().NoError(err, "we were not able to create the user before we try to drop it", err) + + lines, err := suite.runSuiteCmd(strings.Split(tc.cmd, " ")...) + suite.Assert().NoError(err, "error: %s, stdout/err: %s", err, lines) + + if err != nil { + suite.FailNow("failed") + } + + _, err = suite.avsClient.GetUser(context.Background(), tc.user) + suite.Assert().Error(err, "we should not have retrieved the dropped user") + }) + } +} + +// Server treats non-existing users as a no-op in drop cmd +// +// func (suite *CmdTestSuite) TestFailedUserDropCmd() { + +// if suite.avsUser == nil { +// suite.T().Skip("authentication is disabled. skipping test") +// } + +// lines, err := suite.runCmd(strings.Split(fmt.Sprintf("users drop --host %s s --name DNE", suite.avsHostPort.String()), " ")...) +// suite.Assert().Error(err, "error: %s, stdout/err: %s", err, lines) +// suite.Assert().Contains(lines[0], "server error") +// } + +func (suite *CmdTestSuite) TestSuccessfulUserGrantCmd() { + if suite.avsUser == nil { + suite.T().Skip("authentication is disabled. skipping test") + } + + testCases := []struct { + name string + user string + cmd string + expectedUser *protos.User + }{ + { + "grant user", + "grant0", + fmt.Sprintf("users grant --host %s s --name grant0 --roles read-write", suite.avsHostPort.String()), + &protos.User{ + Username: "grant0", + Roles: []string{"read-write", "admin"}, + }, + }, + } + + for _, tc := range testCases { + suite.Run(tc.name, func() { + err := suite.avsClient.CreateUser(context.Background(), tc.user, "foo", []string{"admin"}) + suite.Assert().NoError(err, "we were not able to create the user before we try to grant it", err) + + lines, err := suite.runSuiteCmd(strings.Split(tc.cmd, " ")...) + suite.Assert().NoError(err, "error: %s, stdout/err: %s", err, lines) + + if err != nil { + suite.FailNow("failed") + } + + actualUser, err := suite.avsClient.GetUser(context.Background(), tc.user) + suite.Assert().NoError(err, "error: %s", err) + + suite.Assert().EqualExportedValues(tc.expectedUser, actualUser) + }) + } +} + +func (suite *CmdTestSuite) TestSuccessfulUserRevokeCmd() { + if suite.avsUser == nil { + suite.T().Skip("authentication is disabled. skipping test") + } + + testCases := []struct { + name string + user string + cmd string + expectedUser *protos.User + }{ + { + "revoke user", + "revoke0", + fmt.Sprintf("users revoke --host %s s --name revoke0 --roles read-write", suite.avsHostPort.String()), + &protos.User{ + Username: "revoke0", + Roles: []string{"admin"}, + }, + }, + } + + for _, tc := range testCases { + suite.Run(tc.name, func() { + err := suite.avsClient.CreateUser(context.Background(), tc.user, "foo", []string{"admin", "read-write"}) + suite.Assert().NoError(err, "we were not able to create the user before we try to revoke it", err) + + lines, err := suite.runSuiteCmd(strings.Split(tc.cmd, " ")...) + suite.Assert().NoError(err, "error: %s, stdout/err: %s", err, lines) + + if err != nil { + suite.FailNow("failed") + } + + actualUser, err := suite.avsClient.GetUser(context.Background(), tc.user) + suite.Assert().NoError(err, "error: %s", err) + + suite.Assert().EqualExportedValues(tc.expectedUser, actualUser) + }) + } +} + +func (suite *CmdTestSuite) TestSuccessfulUsersNewPasswordCmd() { + if suite.avsUser == nil { + suite.T().Skip("authentication is disabled. skipping test") + } + + testCases := []struct { + name string + user string + newPassword string + cmd string + }{ + { + "change password", + "password0", + "foo", + fmt.Sprintf("users new-password --host %s s --name password0 --new-password foo", suite.avsHostPort.String()), + }, + } + + for _, tc := range testCases { + suite.Run(tc.name, func() { + err := suite.avsClient.CreateUser(context.Background(), tc.user, "oldpass", []string{"admin"}) + suite.Assert().NoError(err, "we were not able to create the user before we try to change password", err) + + lines, err := suite.runSuiteCmd(strings.Split(tc.cmd, " ")...) + suite.Assert().NoError(err, "error: %s, stdout/err: %s", err, lines) + + if err != nil { + suite.FailNow("failed") + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) + defer cancel() + + _, err = avs.NewAdminClient( + ctx, + avs.HostPortSlice{suite.avsHostPort}, + nil, + true, + &tc.user, + &tc.newPassword, + suite.avsTLSConfig, + logger, + ) + suite.Assert().NoError(err, "error: %s", err) + }) + } +} + +func (suite *CmdTestSuite) TestSuccessfulListUsersCmd() { + if suite.avsUser == nil { + suite.T().Skip("authentication is disabled. skipping test") + } + + testCases := []struct { + name string + cmd string + expectedTable string + }{ + { + "users list", + fmt.Sprintf("users list --seeds %s s", suite.avsHostPort.String()), + `╭───────────────────────────────╮ +│ Users │ +├───┬───────┬───────────────────┤ +│ │ USER │ ROLES │ +├───┼───────┼───────────────────┤ +│ 1 │ admin │ admin, read-write │ +╰───┴───────┴───────────────────╯ +Use 'role list' to view available roles +`, + }, + } + + for _, tc := range testCases { + suite.Run(tc.name, func() { + lines, err := suite.runSuiteCmd(strings.Split(tc.cmd, " ")...) suite.Assert().NoError(err, "error: %s, stdout/err: %s", err, lines) actualTable := removeANSICodes(strings.Join(lines, "\n")) suite.Assert().Equal(tc.expectedTable, actualTable) + }) + } +} + +func (suite *CmdTestSuite) TestFailUserCmdsWithInvalidUser() { + if suite.avsUser == nil { + suite.T().Skip("authentication is disabled. skipping test") + } + + testCases := []struct { + name string + cmd string + expectedErr string + }{ + { + "fail to revoke user to invalid user", + fmt.Sprintf("users revoke --host %s s --name foo1 --roles admin", suite.avsHostPort.String()), + "failed to revoke user roles: server error: NotFound", + }, + { + "fail to grant user to invalid user", + fmt.Sprintf("users grant --host %s s --name foo1 --roles admin", suite.avsHostPort.String()), + "failed to grant user roles: server error: NotFound", + }, + } + for _, tc := range testCases { + suite.Run(tc.name, func() { + lines, err := suite.runSuiteCmd(strings.Split(tc.cmd, " ")...) + suite.Assert().Error(err, "error: %s, stdout/err: %s", err, lines) + suite.Assert().Contains(lines[0], tc.expectedErr) + }) + + } +} + +func (suite *CmdTestSuite) TestFailUserCmdsWithInvalidRoles() { + if suite.avsUser == nil { + suite.T().Skip("authentication is disabled. skipping test") + } + + testCases := []struct { + name string + cmd string + expectedErr string + }{ + { + "fail to grant user with invalid role", + fmt.Sprintf("users grant --host %s s --name foo1 --roles invalid", suite.avsHostPort.String()), + "unknown roles [invalid]", + }, + { + "fail to revoke user with invalid role", + fmt.Sprintf("users revoke --host %s s --name foo1 --roles invalid", suite.avsHostPort.String()), + "unknown roles [invalid]", + }, + } + + for _, tc := range testCases { + suite.Run(tc.name, func() { + lines, err := suite.runSuiteCmd(strings.Split(tc.cmd, " ")...) + suite.Assert().Error(err, "error: %s, stdout/err: %s", err, lines) + suite.Assert().Contains(lines[0], tc.expectedErr) + }) + + } +} + +func (suite *CmdTestSuite) TestSuccessfulListRolesCmd() { + if suite.avsUser == nil { + suite.T().Skip("authentication is disabled. skipping test") + } + + testCases := []struct { + name string + cmd string + expectedTable string + }{ + { + "roles list", + fmt.Sprintf("role list --seeds %s s", suite.avsHostPort.String()), + `╭───┬────────────╮ +│ │ ROLES │ +├───┼────────────┤ +│ 1 │ admin │ +│ 2 │ read-write │ +╰───┴────────────╯ +`, + }, + } + + for _, tc := range testCases { + suite.Run(tc.name, func() { + lines, err := suite.runSuiteCmd(strings.Split(tc.cmd, " ")...) + suite.Assert().NoError(err, "error: %s, stdout/err: %s", err, lines) + + actualTable := removeANSICodes(strings.Join(lines, "\n")) + + suite.Assert().Equal(tc.expectedTable, actualTable) }) } } func (suite *CmdTestSuite) TestFailInvalidArg() { testCases := []struct { - name string - cmd string - errStr string + name string + cmd string + expectedErrStr string }{ { "use seeds and hosts together", - fmt.Sprintf("create index --seeds %s --host 1.1.1.1:3001 -n test -i index1 -d 256 -m SQUARED_EUCLIDEAN --vector-field vector1 --storage-namespace bar --storage-set testbar --timeout 10s", suite.avsHostPort.String()), + fmt.Sprintf("index create -y --seeds %s --host 1.1.1.1:3001 -n test -i index1 -d 256 -m SQUARED_EUCLIDEAN --vector-field vector1 --storage-namespace bar --storage-set testbar s", suite.avsHostPort.String()), "Error: only --seeds or --host allowed", }, { "use seeds and hosts together", - fmt.Sprintf("list index --seeds %s --host 1.1.1.1:3001", suite.avsHostPort.String()), + fmt.Sprintf("index list --seeds %s --host 1.1.1.1:3001", suite.avsHostPort.String()), "Error: only --seeds or --host allowed", }, { "use seeds and hosts together", - fmt.Sprintf("drop index --seeds %s --host 1.1.1.1:3001 -n test -i index1", suite.avsHostPort.String()), + fmt.Sprintf("index drop -y --seeds %s --host 1.1.1.1:3001 -n test -i index1", suite.avsHostPort.String()), "Error: only --seeds or --host allowed", }, { "test with bad dimension", - "create index --host 1.1.1.1:3001 -n test -i index1 -d -1 -m SQUARED_EUCLIDEAN --vector-field vector1 --storage-namespace bar --storage-set testbar --timeout 10s", + "index create -y --host 1.1.1.1:3001 -n test -i index1 -d -1 -m SQUARED_EUCLIDEAN --vector-field vector1 --storage-namespace bar --storage-set testbar s", "Error: invalid argument \"-1\" for \"-d, --dimension\"", }, { "test with bad distance metric", - "create index --host 1.1.1.1:3001 -n test -i index1 -d 10 -m BAD --vector-field vector1 --storage-namespace bar --storage-set testbar --timeout 10s", + "index create -y --host 1.1.1.1:3001 -n test -i index1 -d 10 -m BAD --vector-field vector1 --storage-namespace bar --storage-set testbar s", "Error: invalid argument \"BAD\" for \"-m, --distance-metric\"", }, { "test with bad timeout", - "create index --host 1.1.1.1:3001 -n test -i index1 -d 10 -m SQUARED_EUCLIDEAN --vector-field vector1 --storage-namespace bar --storage-set testbar --timeout 10", + "index create -y --host 1.1.1.1:3001 -n test -i index1 -d 10 -m SQUARED_EUCLIDEAN --vector-field vector1 --storage-namespace bar --storage-set testbar --timeout 10", "Error: invalid argument \"10\" for \"--timeout\"", }, { "test with bad hnsw-batch-enabled", - "create index --hnsw-batch-enabled foo --host 1.1.1.1:3001 -n test -i index1 -d 10 -m SQUARED_EUCLIDEAN --vector-field vector1 --storage-namespace bar --storage-set testbar --timeout 10", + "index create -y --hnsw-batch-enabled foo --host 1.1.1.1:3001 -n test -i index1 -d 10 -m SQUARED_EUCLIDEAN --vector-field vector1 --storage-namespace bar --storage-set testbar", "Error: invalid argument \"foo\" for \"--hnsw-batch-enabled\"", }, { "test with bad hnsw-batch-interval", - "create index --hnsw-batch-interval foo --host 1.1.1.1:3001 -n test -i index1 -d 10 -m SQUARED_EUCLIDEAN --vector-field vector1 --storage-namespace bar --storage-set testbar --timeout 10", + "index create -y --hnsw-batch-interval foo --host 1.1.1.1:3001 -n test -i index1 -d 10 -m SQUARED_EUCLIDEAN --vector-field vector1 --storage-namespace bar --storage-set testbar ", "Error: invalid argument \"foo\" for \"--hnsw-batch-interval\"", }, { "test with bad hnsw-batch-max-records", - "create index --hnsw-batch-max-records foo --host 1.1.1.1:3001 -n test -i index1 -d 10 -m SQUARED_EUCLIDEAN --vector-field vector1 --storage-namespace bar --storage-set testbar --timeout 10", + "index create -y --hnsw-batch-max-records foo --host 1.1.1.1:3001 -n test -i index1 -d 10 -m SQUARED_EUCLIDEAN --vector-field vector1 --storage-namespace bar --storage-set testbar ", "Error: invalid argument \"foo\" for \"--hnsw-batch-max-records\"", }, { "test with bad hnsw-ef", - "create index --hnsw-ef foo --host 1.1.1.1:3001 -n test -i index1 -d 10 -m SQUARED_EUCLIDEAN --vector-field vector1 --storage-namespace bar --storage-set testbar --timeout 10", + "index create -y --hnsw-ef foo --host 1.1.1.1:3001 -n test -i index1 -d 10 -m SQUARED_EUCLIDEAN --vector-field vector1 --storage-namespace bar --storage-set testbar ", "Error: invalid argument \"foo\" for \"--hnsw-ef\"", }, { "test with bad hnsw-ef-construction", - "create index --hnsw-ef-construction foo --host 1.1.1.1:3001 -n test -i index1 -d 10 -m SQUARED_EUCLIDEAN --vector-field vector1 --storage-namespace bar --storage-set testbar --timeout 10", + "index create -y --hnsw-ef-construction foo --host 1.1.1.1:3001 -n test -i index1 -d 10 -m SQUARED_EUCLIDEAN --vector-field vector1 --storage-namespace bar --storage-set testbar ", "Error: invalid argument \"foo\" for \"--hnsw-ef-construction\"", }, { "test with bad hnsw-max-edges", - "create index --hnsw-max-edges foo --host 1.1.1.1:3001 -n test -i index1 -d 10 -m SQUARED_EUCLIDEAN --vector-field vector1 --storage-namespace bar --storage-set testbar --timeout 10", + "index create -y --hnsw-max-edges foo --host 1.1.1.1:3001 -n test -i index1 -d 10 -m SQUARED_EUCLIDEAN --vector-field vector1 --storage-namespace bar --storage-set testbar ", "Error: invalid argument \"foo\" for \"--hnsw-max-edges\"", }, + { + "test with bad password", + "user create --password file:blah --name foo --roles admin", + "blah: no such file or directory", + }, + { + "test with bad tls-cafile", + "user create --tls-cafile blah --name foo --roles admin", + "blah: no such file or directory", + }, + { + "test with bad tls-capath", + "user create --tls-capath blah --name foo --roles admin", + "blah: no such file or directory", + }, + { + "test with bad tls-certfile", + "user create --tls-certfile blah --name foo --roles admin", + "blah: no such file or directory", + }, + { + "test with bad tls-keyfile", + "user create --tls-keyfile blah --name foo --roles admin", + "blah: no such file or directory", + }, + { + "test with bad tls-keyfile-password", + "user create --tls-keyfile-password b64:bla65asdf54r345123!@#$h --name foo --roles admin", + "Error: invalid argument \"b64:bla65asdf54r345123!@#$h\"", + }, } for _, tc := range testCases { @@ -581,7 +1021,7 @@ func (suite *CmdTestSuite) TestFailInvalidArg() { lines, err := suite.runCmd(strings.Split(tc.cmd, " ")...) suite.Assert().Error(err, "error: %s, stdout/err: %s", err, lines) - suite.Assert().Contains(lines[0], tc.errStr) + suite.Assert().Contains(lines[0], tc.expectedErrStr) }) } } diff --git a/main.go b/main.go index 23be779..581cb68 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,3 @@ -/* -Copyright © 2024 NAME HERE -*/ package main import "asvec/cmd"