diff --git a/.github/actions/build/action.yaml b/.github/actions/build/action.yaml new file mode 100644 index 00000000..666295f4 --- /dev/null +++ b/.github/actions/build/action.yaml @@ -0,0 +1,16 @@ +name: Build +description: "Build pipeline" +inputs: + go-version: + description: "Go version to install" + required: true +runs: + using: "composite" + steps: + - name: Install Go ${{ inputs.go-version }} + uses: actions/setup-go@v4 + with: + go-version: ${{ inputs.go-version }} + - name: Install project tools and dependencies + shell: bash + run: make project-tools \ No newline at end of file diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 00000000..1712debc --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,22 @@ +name: CI Workflow + +on: [pull_request, workflow_dispatch] + +env: + GO_VERSION: "1.21" + +jobs: + main: + name: CI + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Build + uses: ./.github/actions/build + with: + go-version: ${{ env.GO_VERSION }} + - name: Lint + run: make lint + - name: Test + run: make test diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 00000000..e9cd1a96 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,34 @@ +# STACKIT CLI release workflow. +name: Release + +# This GitHub action creates a release when a tag that matches the pattern +# "v*" (e.g. v0.1.0) is created. +on: + push: + tags: + - "v*" + workflow_dispatch: + +# Releases need permissions to read and write the repository contents. +# GitHub considers creating releases and uploading assets as writing contents. +permissions: + contents: write + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + # Allow goreleaser to access older tag information. + fetch-depth: 0 + - uses: actions/setup-go@v4 + with: + go-version-file: "go.mod" + cache: true + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v5 + with: + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.CLI_RELEASE }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..70c5d7e8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# Binaries +bin/ +dist/ + +# IDE +.vscode + +# OS generated files +.DS_Store diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 00000000..39caa2d8 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,76 @@ +before: + hooks: + - go mod tidy + +builds: + - id: linux-builds + env: + - CGO_ENABLED=0 + goos: + - linux + binary: "stackit" + + - id: windows-builds + env: + - CGO_ENABLED=0 + goos: + - windows + binary: "stackit" + + - id: macos-builds + env: + - CGO_ENABLED=0 + goos: + - darwin + binary: "stackit" + +archives: + - format: tar.gz + # This name template makes the OS and Arch compatible with the results of `uname` + name_template: >- + {{ .ProjectName }}_ + {{- title .Os }}_ + {{- if eq .Arch "amd64" }}x86_64 + {{- else if eq .Arch "386" }}i386 + {{- else }}{{ .Arch }}{{ end }} + {{- if .Arm }}v{{ .Arm }}{{ end }} + format_overrides: + - goos: windows + format: zip + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + +nfpms: + - id: linux-packages + # IDs of the builds for which to create packages for + builds: + - linux-builds + vendor: STACKIT + homepage: https://github.com/stackitcloud/stackit-cli + maintainer: STACKIT + description: A command-line interface to manage STACKIT resources. + license: Apache 2.0 + formats: + - deb + - rpm + +brews: + - name: stackit-cli + repository: + owner: stackitcloud + name: homebrew-tap + commit_author: + name: CLI Release Bot + email: noreply@stackit.de + homepage: "https://github.com/stackitcloud/stackit-cli" + description: "A command-line interface to manage STACKIT resources." + license: "Apache-2.0" + # If set to auto, the release will not be uploaded to the homebrew tap repo + # if the tag has a prerelease indicator (e.g. v0.0.1-alpha1) + # Not setting it for now to test with a prerelease tag + # skip_upload: auto \ No newline at end of file diff --git a/AUTHENTICATION.md b/AUTHENTICATION.md new file mode 100644 index 00000000..211f7c3d --- /dev/null +++ b/AUTHENTICATION.md @@ -0,0 +1,101 @@ +# Authentication Guide + +This document describes how you can configure authentication for the STACKIT CLI. + +## Service account + +You can use a [service account](https://docs.stackit.cloud/stackit/en/service-accounts-134415819.html) to authenticate to the STACKIT CLI. +The CLI will search for service account credentials similarly to the [STACKIT SDK](https://github.com/stackitcloud/stackit-sdk-go) and [Terraform Provider](https://github.com/stackitcloud/terraform-provider-stackit), so if you have setup you environment previously for those tools, you can just run: + +```bash +$ ./bin/stackit auth activate-service-account +``` + +You can also configure the service account credentials directly in the CLI. To get help and to get a list of the available options run the command with the `-h` flag. + +### Overview + +If you dont have a service account, create one in the STACKIT Portal an assign it the necessary permissions, e.g. `project.owner`. There are two ways to authenticate: + +- Key flow (recommended) +- Token flow + +When setting up authentication, the CLI will always try to use the key flow first and search for credentials in several locations, following a specific order: + +1. Explicitly provided credentials, e.g. by using the flag `--service-account-key-path` +2. Environment variable, e.g. by setting `STACKIT_SERVICE_ACCOUNT_KEY_PATH` +3. Credentials file + + The CLI will check the credentials file located in the path defined by the `STACKIT_CREDENTIALS_PATH` env var, if specified, + or in `$HOME/.stackit/credentials.json` as a fallback. + The credentials file should be a JSON and each credential should be set using the name of the respective environment variable, as stated below in each flow. Example: + + ```json + { + "STACKIT_SERVICE_ACCOUNT_TOKEN": "foo_token", + "STACKIT_SERVICE_ACCOUNT_KEY_PATH": "path/to/sa_key.json" + } + ``` + +### Key flow + + The following instructions assume that you have created a service account and assigned it the necessary permissions, e.g. `project.owner`. + +To use the key flow, you need to have a service account key, which must have an RSA key-pair attached to it. + +When creating the service account key, a new RSA key-pair can be created automatically, which will be included in the service account key. This will make it much easier to configure the key flow authentication in the CLI, by just providing the service account key. + +**Optionally**, you can provide your own private key when creating the service account key, which will then require you to also provide it explicitly to the CLI, additionaly to the service account key. Check the STACKIT Knowledge Base for an [example of how to create your own key-pair](https://docs.stackit.cloud/stackit/en/usage-of-the-service-account-keys-in-stackit-175112464.html#UsageoftheserviceaccountkeysinSTACKIT-CreatinganRSAkey-pair). + +To configure the key flow, follow this steps: + +1. Create a service account key: + +- In the CLI, run `stackit service-account key create --email ` +- As an alternative, use the STACKIT Portal: go to the `Service Accounts` tab, choose a `Service Account` and go to `Service Account Keys` to create a key. For more details, see [Create a service account key](https://docs.stackit.cloud/stackit/en/create-a-service-account-key-175112456.html) + +2. Save the content of the service account key by copying it and saving it in a JSON file. + +The expected format of the service account key is a **json** with the following structure: + +```json +{ + "id": "uuid", + "publicKey": "public key", + "createdAt": "2023-08-24T14:15:22Z", + "validUntil": "2023-08-24T14:15:22Z", + "keyType": "USER_MANAGED", + "keyOrigin": "USER_PROVIDED", + "keyAlgorithm": "RSA_2048", + "active": true, + "credentials": { + "kid": "string", + "iss": "my-sa@sa.stackit.cloud", + "sub": "uuid", + "aud": "string", + (optional) "privateKey": "private key when generated by the SA service" + } +} +``` + +3. Configure the service account key for authentication in the CLI by following one of the alternatives below: + + - using the flag `--service-account-key-path` + - setting the environment variable `STACKIT_SERVICE_ACCOUNT_KEY_PATH` + - setting `STACKIT_SERVICE_ACCOUNT_KEY_PATH` in the credentials file (see above) + +> **Optionally, only if you have provided your own RSA key-pair when creating the service account key**, you also need to configure your private key (takes precedence over the one included in the service account key, if present). **The private key must be PEM encoded** and can be provided using one of the options below: +> +> - using the flag `--private-key-path` +> - setting the environment variable `STACKIT_PRIVATE_KEY_PATH` +> - setting `STACKIT_PRIVATE_KEY_PATH` in the credentials file (see above) + +4. The CLI will search for the keys and, if valid, will use them to get access and refresh tokens which will be used to authenticate all the requests. + +### Token flow + +Using this flow is less secure since the token is long-lived. You can provide the token in several ways: + +1. Providing the flag `--service-account-token` +2. Setting the environment variable `STACKIT_SERVICE_ACCOUNT_TOKEN` +3. Setting `STACKIT_SERVICE_ACCOUNT_TOKEN` in the credentials file (see above) diff --git a/CONTRIBUTION.md b/CONTRIBUTION.md new file mode 100644 index 00000000..81b1198f --- /dev/null +++ b/CONTRIBUTION.md @@ -0,0 +1,73 @@ +# Contribute to the STACKIT CLI + +Your contribution is welcome! Thank you for your interest in contributing to the STACKIT CLI. We greatly value your feedback, feature requests, additions to the code, bug reports or documentation extensions. + +## Table of contents + +- [Developer Guide](#developer-guide) +- [Code Contributions](#code-contributions) +- [Bug Reports](#bug-reports) + +## Developer Guide + +### Repository structure + +The CLI commands are located under `internal/cmd`, where each folder includes the source code for a `group` of commands. Inside `pkg` you can find several useful packages that are shared by the commands and provide additional functionality such as `flags`, `globalflags`, `tables`, etc. + +### Getting started + +Check the [Authentication](README.md#authentication) section on the README. + +#### Useful Make commands + +These commands can be executed from the project root: + +- `make project-tools`: install the required dependencies +- `make build`: compiles the CLI and saves the binary under _./bin/stackit_ +- `make lint`: lint the code and examples +- `make generate-docs`: generates Markdown documentation for every command +- `make test`: run unit tests + +#### Local development + +To test your changes, you can either: + +1. Build the application locally by running: + + ```bash + $ go build -o ./bin/stackit + ``` + + To use the application from the root of the repository, you can run: + + ```bash + $ ./bin/stackit [group] [subgroup] [command] [flags] + ``` + +2. Skip building and run the Go application directly using: + + ```bash + $ go run . [group] [subgroup] [command] [flags] + ``` + +## Code Contributions (Coming soon to GitHub!) + +To make your contribution, follow these steps: + +1. Check open or recently closed [Pull Requests](https://github.com/stackitcloud/stackit-cli/pulls) and [Issues](https://github.com/stackitcloud/stackit-cli/issues)to make sure the contribution you are making has not been already tackled by someone else. +2. Fork the repo. +3. Make your changes in a branch that is up-to-date with the original repo's `main` branch. +4. Commit your changes including a descriptive message +5. Create a pull request with your changes. +6. The pull request will be reviewed by the repo maintainers. If you need to make further changes, make additional commits to keep commit history. When the PR is merged, commits will be squashed. + +## Bug Reports (Coming soon to GitHub!) + +If you would like to report a bug, please open a [GitHub issue](https://github.com/stackitcloud/stackit-cli/issues/new). + +To ensure we can provide the best support to your issue, follow these guidelines: + +1. Go through the existing issues to check if your issue has already been reported. +2. Make sure you are using the latest version of the provider, we will not provide bug fixes for older versions. Also, latest versions may have the fix for your bug. +3. Please provide as much information as you can about your environment, e.g. your version of Go, your version of the provider, which operating system you are using and the corresponding version. +4. Include in your issue the steps to reproduce it, along with code snippets and/or information about your specific use case. This will make the support process much easier and efficient. diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..e69de29b diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..bee32114 --- /dev/null +++ b/Makefile @@ -0,0 +1,32 @@ +ROOT_DIR ?= $(shell git rev-parse --show-toplevel) +SCRIPTS_BASE ?= $(ROOT_DIR)/scripts +GOLANG_CI_YAML_PATH ?= ${ROOT_DIR}/golang-ci.yaml +GOLANG_CI_ARGS ?= --allow-parallel-runners --timeout=5m --config=${GOLANG_CI_YAML_PATH} + +# Build +build: + @go build -o ./bin/stackit + +# Setup and tool initialization tasks +project-help: + @$(SCRIPTS_BASE)/project.sh help + +project-tools: + @$(SCRIPTS_BASE)/project.sh tools + +# Lint +lint-golangci-lint: + @echo "Linting with golangci-lint" + @golangci-lint run ${GOLANG_CI_ARGS} + +lint: lint-golangci-lint + +# Test +test: + @echo "Running tests for the CLI application" + @go test ./... -count=1 + +# Generate docs +generate-docs: + @echo "Generating docs..." + @go run $(SCRIPTS_BASE)/generate.go \ No newline at end of file diff --git a/NOTICE.txt b/NOTICE.txt new file mode 100644 index 00000000..33aab03d --- /dev/null +++ b/NOTICE.txt @@ -0,0 +1,2 @@ +STACKIT CLI +Copyright 2024 Schwarz IT GmBH & Co. KG \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 00000000..1ca72fd9 --- /dev/null +++ b/README.md @@ -0,0 +1,135 @@ +# STACKIT CLI + +Welcome to the STACKIT CLI, a command-line interface for the STACKIT services. + +## Installation + +The STACKIT CLI is available as a downloadable binary, stored as an artifact on our [build pipeline](https://dev.azure.com/schwarzit/schwarzit.stackit-public/_build?definitionId=24619). Please find the execution associated with the most recent tag for the most up-to-date version. + +To get started using it, you can: + +1. Download the binary corresponding to your operating system and CPU architecture +2. Extract the contents of the file to your file system and move it to your preferred location (e.g. your home directory) +3. (For macOS only) Right click on the executable, select "Open". You will see a dialog stating the identity of the developer cannot be confirmed. Click on "Open" to allow the app to run on your Mac. We soon plan to certificate the STACKIT CLI to be trusted by macOS + +Alternatively, you can use the STACKIT CLI by cloning the repository and either: + +1. Build the application locally by running: + + ```bash + $ go build -o ./bin/stackit + ``` + + To use the application from the root of the repository, you can run: + + ```bash + $ ./bin/stackit + ``` + +2. Skip building and run the Go application directly using: + + ```bash + $ go run . + ``` + +We also plan to integrate the STACKIT CLI on package managers such as APT and Brew. + +## Usage + +A typical command is structured as: + +``` +stackit [OPTION FLAGS] +``` + +- `` can be the name of a service, such as `dns` or `mongodbflex`, or other groups for additional functionality, such as `config` to configure the CLI or `auth` to authenticate. +- `` should be the name (singular form) of a service resource, when `` is the name of a service. Examples: `zone`, `instance`. +- `` is a command associated to the innermost group. Usually it's an action for the resource in question, such as `list` (to show all resources of the given type) or the CRUD operations `create`, `describe`, `update` and `delete`. +- `` is required by some commands to specify a resource identifier. Examples: `stackit dns zone delete ZONE_ID`, `stackit ske cluster create CLUSTER_NAME`. +- `` is a list of inputs necessary to execute the command, in the format `--[flag]` or `--[flag] [value]`. Some are required, while others are optional. +- `[OPTION FLAGS]` is a set of optional settings that modify the command's execution context. Examples: `--output-format=json` changes the format of the output to JSON, `--assume-yes` skips confirmation prompts. + +Examples: + +- `stackit ske cluster describe my-cluster --project-id xxx --output-format json` +- `stackit mongodbflex instance create --name my-instance --cpu 1 --ram 4 --acl 0.0.0.0/0 --assume-yes` +- `stackit dns zone delete my-zone` + +Some commands are implemented at the root, group or sub-group level: + +- `stackit config` to define variables to be used in future commands. +- `stackit ske enable` to enable the SKE engine on your project. + +Help is available for any command by specifying the special flag `--help` (or simply `-h`): + +- `stackit --help` +- `stackit -h` +- `stackit --help` +- `stackit --help` +- `stackit --help` + +## Authentication + +Most of the commands will require you to be authenticated. Currently it's possible to authenticate with your personal user or with a service account. + +After successful authentication, the CLI stores credentials in your OS keychain. You won't need to login again for the duration of your session, which is 2h by default but configurable by providing the `--session-time-limit` flag on the `config set` command (see [Configuration](#configuration)). + +### Login with a personal user account + +To authenticate as a user, run the command below and follow the steps in your browser. + +```bash +$ stackit auth login +``` + +### Activate a service account + +To authenticate using a service account, run: + +```bash +$ stackit auth activate-service-account +``` + +For more details on how to setup authentication using a service account, check our [Authentication guide](./AUTHENTICATION.md) + +## Configuration + +You can configure the CLI using the command: + +```bash +$ stackit config +``` + +The configurations are stored in `~/stackit/cli-config.json` and are valid for all commands. For example, you can set a default `project-id` by running: + +```bash +$ stackit config set --project-id xxxx-xxxx-xxxxx +``` + +To remove it, you can run: + +```bash +$ stackit config unset --project-id +``` + +Run the `config set` command with the flag `--help` to get a list of all of the available configuration options. + +You can lookup your current configuration by checking the configuration file or by running: + +```bash +$ stackit config list +``` + +You can also edit the configuration file manually. + +## Reporting issues + +If you encounter any issues or have suggestions for improvements, please reach out to the Developer Tools team or open a ticket through the [STACKIT Help Center](https://support.stackit.cloud/). + +## Contribute (Coming soon to GitHub!) + +Your contribution is welcome! For more details on how to contribute, refer to our [Contribution Guide](./CONTRIBUTION.md). + +## License + +Apache 2.0 diff --git a/docs/stackit.md b/docs/stackit.md new file mode 100644 index 00000000..04061303 --- /dev/null +++ b/docs/stackit.md @@ -0,0 +1,31 @@ +## stackit + +Manage STACKIT resources using the command line + +``` +stackit [flags] +``` + +### Options + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -h, --help Help for "stackit" + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID + -v, --version Show "stackit" version +``` + +### SEE ALSO + +* [stackit auth](./stackit_auth.md) - Provides authentication functionality +* [stackit config](./stackit_config.md) - CLI configuration options +* [stackit curl](./stackit_curl.md) - Execute an authenticated HTTP request to an endpoint +* [stackit dns](./stackit_dns.md) - Provides functionality for DNS +* [stackit mongodbflex](./stackit_mongodbflex.md) - Provides functionality for MongoDB Flex +* [stackit organization](./stackit_organization.md) - Provides functionality regarding organizations +* [stackit project](./stackit_project.md) - Provides functionality regarding projects +* [stackit service-account](./stackit_service-account.md) - Provides functionality for service accounts +* [stackit ske](./stackit_ske.md) - Provides functionality for SKE + diff --git a/docs/stackit_auth.md b/docs/stackit_auth.md new file mode 100644 index 00000000..479b406d --- /dev/null +++ b/docs/stackit_auth.md @@ -0,0 +1,33 @@ +## stackit auth + +Provides authentication functionality + +### Synopsis + +Provides authentication functionality + +``` +stackit auth [flags] +``` + +### Options + +``` + -h, --help Help for "stackit auth" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit](./stackit.md) - Manage STACKIT resources using the command line +* [stackit auth activate-service-account](./stackit_auth_activate-service-account.md) - Activate service account authentication +* [stackit auth login](./stackit_auth_login.md) - Login to the STACKIT CLI + diff --git a/docs/stackit_auth_activate-service-account.md b/docs/stackit_auth_activate-service-account.md new file mode 100644 index 00000000..3c2d9309 --- /dev/null +++ b/docs/stackit_auth_activate-service-account.md @@ -0,0 +1,51 @@ +## stackit auth activate-service-account + +Activate service account authentication + +### Synopsis + +Activate authentication using service account credentials. +Subsequent commands will be authenticated using the service account credentials provided. +For more details on how to configure your service account, check our Authentication guide at https://dev.azure.com/schwarzit/schwarzit.stackit-public/_git/stackit-cli-beta?path=/AUTHENTICATION.md. + +``` +stackit auth activate-service-account [flags] +``` + +### Examples + +``` + Activate service account authentication in the STACKIT CLI using a service account key which includes the private key + $ stackit auth activate-service-account --service-account-key-path path/to/service_account_key.json + + Activate service account authentication in the STACKIT CLI using the service account key and explicitly providing the private key in a PEM encoded file, which will take precedence over the one in the service account key + $ stackit auth activate-service-account --service-account-key-path path/to/service_account_key.json --private-key-path path/to/private.key + + Activate service account authentication in the STACKIT CLI using the service account token + $ stackit auth activate-service-account --service-account-token my-service-account-token +``` + +### Options + +``` + -h, --help Help for "stackit auth activate-service-account" + --jwks-custom-endpoint string Custom endpoint for the jwks API, which is used to get the json web key sets (jwks) to validate tokens when the service-account authentication is activated + --private-key-path string RSA private key path. It takes precedence over the private key included in the service account key, if present + --service-account-key-path string Service account key path + --service-account-token string Service account long-lived access token + --token-custom-endpoint string Custom endpoint for the token API, which is used to request access tokens when the service-account authentication is activated +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit auth](./stackit_auth.md) - Provides authentication functionality + diff --git a/docs/stackit_auth_login.md b/docs/stackit_auth_login.md new file mode 100644 index 00000000..8b0b27a6 --- /dev/null +++ b/docs/stackit_auth_login.md @@ -0,0 +1,38 @@ +## stackit auth login + +Login to the STACKIT CLI + +### Synopsis + +Login to the STACKIT CLI + +``` +stackit auth login [flags] +``` + +### Examples + +``` + Login to the STACKIT CLI. This command will open a browser window where you can login to your STACKIT account + $ stackit auth login +``` + +### Options + +``` + -h, --help Help for "stackit auth login" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit auth](./stackit_auth.md) - Provides authentication functionality + diff --git a/docs/stackit_config.md b/docs/stackit_config.md new file mode 100644 index 00000000..cfda53b1 --- /dev/null +++ b/docs/stackit_config.md @@ -0,0 +1,34 @@ +## stackit config + +CLI configuration options + +### Synopsis + +CLI configuration options + +``` +stackit config [flags] +``` + +### Options + +``` + -h, --help Help for "stackit config" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit](./stackit.md) - Manage STACKIT resources using the command line +* [stackit config list](./stackit_config_list.md) - List the current CLI configuration values +* [stackit config set](./stackit_config_set.md) - Set CLI configuration options +* [stackit config unset](./stackit_config_unset.md) - Unset CLI configuration options + diff --git a/docs/stackit_config_list.md b/docs/stackit_config_list.md new file mode 100644 index 00000000..af099a6c --- /dev/null +++ b/docs/stackit_config_list.md @@ -0,0 +1,38 @@ +## stackit config list + +List the current CLI configuration values + +### Synopsis + +List the current CLI configuration values + +``` +stackit config list [flags] +``` + +### Examples + +``` + List your active configuration + $ stackit config list +``` + +### Options + +``` + -h, --help Help for "stackit config list" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit config](./stackit_config.md) - CLI configuration options + diff --git a/docs/stackit_config_set.md b/docs/stackit_config_set.md new file mode 100644 index 00000000..318fce5d --- /dev/null +++ b/docs/stackit_config_set.md @@ -0,0 +1,54 @@ +## stackit config set + +Set CLI configuration options + +### Synopsis + +Set CLI configuration options. +All of the configuration options can be set using an environment variable, which takes precedence over what is configured. +The environment variable is the name of the flag, with underscores ("_") instead of dashes ("-") and the "STACKIT" prefix. +Example: to set the project ID you can set the environment variable STACKIT_PROJECT_ID + +``` +stackit config set [flags] +``` + +### Examples + +``` + Set a project ID in your active configuration. This project ID will be used by every command, as long as it's not overridden by the "STACKIT_PROJECT_ID" environment variable or the command flag + $ stackit config set --project-id xxx + + Set the session time limit to 1 hour. After this time you will be prompted to login again to be able to execute commands that need authentication + $ stackit config set --session-time-limit 1h + + Set the DNS custom endpoint. This endpoint will be used on all calls to the DNS API, unless overridden by the "STACKIT_DNS_CUSTOM_ENDPOINT" environment variable + $ stackit config set --dns-custom-endpoint https://dns.stackit.cloud +``` + +### Options + +``` + --dns-custom-endpoint string DNS custom endpoint + -h, --help Help for "stackit config set" + --membership-custom-endpoint string Membership custom endpoint + --mongodbflex-custom-endpoint string MongoDB Flex custom endpoint + --resource-manager-custom-endpoint string Resource manager custom endpoint + --service-account-custom-endpoint string Service Account custom endpoint + --session-time-limit string Maximum time before authentication is required again. Can't be larger than 24h. Examples: 3h, 5h30m40s (BETA: currently values greater than 2h have no effect) + --ske-custom-endpoint string SKE custom endpoint +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit config](./stackit_config.md) - CLI configuration options + diff --git a/docs/stackit_config_unset.md b/docs/stackit_config_unset.md new file mode 100644 index 00000000..8989dc9d --- /dev/null +++ b/docs/stackit_config_unset.md @@ -0,0 +1,50 @@ +## stackit config unset + +Unset CLI configuration options + +### Synopsis + +Unset CLI configuration options + +``` +stackit config unset [flags] +``` + +### Examples + +``` + Unset the project ID stored in your configuration + $ stackit config unset --project-id + + Unset the session time limit stored in your configuration + $ stackit config unset --session-time-limit + + Unset the DNS custom endpoint stored in your configuration + $ stackit config unset --dns-custom-endpoint +``` + +### Options + +``` + --async Configuration option to run commands asynchronously + --dns-custom-endpoint DNS custom endpoint + -h, --help Help for "stackit config unset" + --membership-custom-endpoint Membership custom endpoint + --mongodbflex-custom-endpoint MongoDB Flex custom endpoint + --output-format Output format + --project-id Project ID + --resource-manager-custom-endpoint Resource Manager custom endpoint + --service-account-custom-endpoint SKE custom endpoint + --ske-custom-endpoint SKE custom endpoint +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts +``` + +### SEE ALSO + +* [stackit config](./stackit_config.md) - CLI configuration options + diff --git a/docs/stackit_curl.md b/docs/stackit_curl.md new file mode 100644 index 00000000..6fc804a4 --- /dev/null +++ b/docs/stackit_curl.md @@ -0,0 +1,53 @@ +## stackit curl + +Execute an authenticated HTTP request to an endpoint + +### Synopsis + +Execute an HTTP request to an endpoint, using the authentication provided by the CLI + +``` +stackit curl URL [flags] +``` + +### Examples + +``` + Make a GET request to http://locahost:8000 + $ stackit curl http://locahost:8000 + + Make a GET request to http://locahost:8000, write complete response (headers and body) to file "./output.txt" + $ stackit curl http://locahost:8000 -include --output ./output.txt + + Make a POST request to http://locahost:8000 with payload from file "./payload.json" + $ stackit curl http://locahost:8000 -X POST --data @./payload.json + + Make a POST request to http://locahost:8000 with header "Foo: Bar", fail if server returns error (such as 403 Forbidden) + $ stackit curl http://locahost:8000 -X POST -H "Foo: Bar" --fail +``` + +### Options + +``` + --data string Content to include in the request body. Can be a string or a file path prefixed with "@" + --fail If set, exits with error 22 if response code is 4XX or 5XX + -H, --header strings Custom headers to include in the request, can be specified multiple times. If the "Authorization" header is set, it will override the authentication provided by the CLI + -h, --help Help for "stackit curl" + --include If set, response headers are added to the output + --output string Writes output to provided file instead of printing to console + -X, --request string HTTP method, defaults to GET +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit](./stackit.md) - Manage STACKIT resources using the command line + diff --git a/docs/stackit_dns.md b/docs/stackit_dns.md new file mode 100644 index 00000000..8663ea82 --- /dev/null +++ b/docs/stackit_dns.md @@ -0,0 +1,33 @@ +## stackit dns + +Provides functionality for DNS + +### Synopsis + +Provides functionality for DNS + +``` +stackit dns [flags] +``` + +### Options + +``` + -h, --help Help for "stackit dns" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit](./stackit.md) - Manage STACKIT resources using the command line +* [stackit dns record-set](./stackit_dns_record-set.md) - Provides functionality for DNS record set +* [stackit dns zone](./stackit_dns_zone.md) - Provides functionality for DNS zones + diff --git a/docs/stackit_dns_record-set.md b/docs/stackit_dns_record-set.md new file mode 100644 index 00000000..6159648f --- /dev/null +++ b/docs/stackit_dns_record-set.md @@ -0,0 +1,36 @@ +## stackit dns record-set + +Provides functionality for DNS record set + +### Synopsis + +Provides functionality for DNS record set + +``` +stackit dns record-set [flags] +``` + +### Options + +``` + -h, --help Help for "stackit dns record-set" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit dns](./stackit_dns.md) - Provides functionality for DNS +* [stackit dns record-set create](./stackit_dns_record-set_create.md) - Creates a DNS record set +* [stackit dns record-set delete](./stackit_dns_record-set_delete.md) - Delete a DNS record set +* [stackit dns record-set describe](./stackit_dns_record-set_describe.md) - Get details of a DNS record set +* [stackit dns record-set list](./stackit_dns_record-set_list.md) - List DNS record sets +* [stackit dns record-set update](./stackit_dns_record-set_update.md) - Updates a DNS record set + diff --git a/docs/stackit_dns_record-set_create.md b/docs/stackit_dns_record-set_create.md new file mode 100644 index 00000000..d68c32cd --- /dev/null +++ b/docs/stackit_dns_record-set_create.md @@ -0,0 +1,44 @@ +## stackit dns record-set create + +Creates a DNS record set + +### Synopsis + +Creates a DNS record set + +``` +stackit dns record-set create [flags] +``` + +### Examples + +``` + Create a DNS record set with name "my-rr" with records "1.2.3.4" and "5.6.7.8" in zone with ID "xxx" + $ stackit dns record-set create --zone-id xxx --name my-rr --record 1.2.3.4 --record 5.6.7.8 +``` + +### Options + +``` + --comment string User comment + -h, --help Help for "stackit dns record-set create" + --name string Name of the record, should be compliant with RFC1035, Section 2.3.4 + --record strings Records belonging to the record set + --ttl int Time to live, if not provided defaults to the zone's default TTL + --type string Record type, one of ["A" "AAAA" "SOA" "CNAME" "NS" "MX" "TXT" "SRV" "PTR" "ALIAS" "DNAME" "CAA"] (default "A") + --zone-id string Zone ID +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit dns record-set](./stackit_dns_record-set.md) - Provides functionality for DNS record set + diff --git a/docs/stackit_dns_record-set_delete.md b/docs/stackit_dns_record-set_delete.md new file mode 100644 index 00000000..35094f59 --- /dev/null +++ b/docs/stackit_dns_record-set_delete.md @@ -0,0 +1,39 @@ +## stackit dns record-set delete + +Delete a DNS record set + +### Synopsis + +Delete a DNS record set + +``` +stackit dns record-set delete RECORD_SET_ID [flags] +``` + +### Examples + +``` + Delete DNS record set with ID "xxx" in zone with ID "yyy" + $ stackit dns record-set delete xxx --zone-id yyy +``` + +### Options + +``` + -h, --help Help for "stackit dns record-set delete" + --zone-id string Zone ID +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit dns record-set](./stackit_dns_record-set.md) - Provides functionality for DNS record set + diff --git a/docs/stackit_dns_record-set_describe.md b/docs/stackit_dns_record-set_describe.md new file mode 100644 index 00000000..7b78b819 --- /dev/null +++ b/docs/stackit_dns_record-set_describe.md @@ -0,0 +1,42 @@ +## stackit dns record-set describe + +Get details of a DNS record set + +### Synopsis + +Get details of a DNS record set + +``` +stackit dns record-set describe RECORD_SET_ID [flags] +``` + +### Examples + +``` + Get details of DNS record set with ID "xxx" in zone with ID "yyy" + $ stackit dns record-set describe xxx --zone-id yyy + + Get details of DNS record set with ID "xxx" in zone with ID "yyy" in a table format + $ stackit dns record-set describe xxx --zone-id yyy --output-format pretty +``` + +### Options + +``` + -h, --help Help for "stackit dns record-set describe" + --zone-id string Zone ID +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit dns record-set](./stackit_dns_record-set.md) - Provides functionality for DNS record set + diff --git a/docs/stackit_dns_record-set_list.md b/docs/stackit_dns_record-set_list.md new file mode 100644 index 00000000..af5db51e --- /dev/null +++ b/docs/stackit_dns_record-set_list.md @@ -0,0 +1,58 @@ +## stackit dns record-set list + +List DNS record sets + +### Synopsis + +List DNS record sets. Successfully deleted record sets are not listed by default. + +``` +stackit dns record-set list [flags] +``` + +### Examples + +``` + List DNS record-sets for zone with ID "xxx" + $ stackit dns record-set list --zone-id xxx + + List DNS record-sets for zone with ID "xxx" in JSON format + $ stackit dns record-set list --zone-id xxx --output-format json + + List active DNS record-sets for zone with ID "xxx" + $ stackit dns record-set list --zone-id xxx --is-active true + + List up to 10 DNS record-sets for zone with ID "xxx" + $ stackit dns record-set list --zone-id xxx --limit 10 + + List the deleted DNS record-sets for zone with ID "xxx" + $ stackit dns record-set list --zone-id xxx --deleted +``` + +### Options + +``` + --active Filter for active record sets + --deleted Filter for deleted record sets + -h, --help Help for "stackit dns record-set list" + --inactive Filter for inactive record sets. Deleted record sets are always inactive and will be included when this flag is set + --limit int Maximum number of entries to list + --name-like string Filter by name + --order-by-name string Order by name, one of ["asc" "desc"] + --page-size int Number of items fetched in each API call. Does not affect the number of items in the command output (default 100) + --zone-id string Zone ID +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit dns record-set](./stackit_dns_record-set.md) - Provides functionality for DNS record set + diff --git a/docs/stackit_dns_record-set_update.md b/docs/stackit_dns_record-set_update.md new file mode 100644 index 00000000..86a0297a --- /dev/null +++ b/docs/stackit_dns_record-set_update.md @@ -0,0 +1,43 @@ +## stackit dns record-set update + +Updates a DNS record set + +### Synopsis + +Updates a DNS record set. Performs a partial update; fields not provided are kept unchanged + +``` +stackit dns record-set update RECORD_SET_ID [flags] +``` + +### Examples + +``` + Update the time to live of the record-set with ID "xxx" for zone with ID "yyy" + $ stackit dns record-set update xxx --zone-id yyy --ttl 100 +``` + +### Options + +``` + --comment string User comment + -h, --help Help for "stackit dns record-set update" + --name string Name of the record, should be compliant with RFC1035, Section 2.3.4 + --record strings Records belonging to the record set. If this flag is used, records already created that aren't set when running the command will be deleted + --ttl int Time to live, if not provided defaults to the zone's default TTL + --zone-id string Zone ID +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit dns record-set](./stackit_dns_record-set.md) - Provides functionality for DNS record set + diff --git a/docs/stackit_dns_zone.md b/docs/stackit_dns_zone.md new file mode 100644 index 00000000..ca947071 --- /dev/null +++ b/docs/stackit_dns_zone.md @@ -0,0 +1,36 @@ +## stackit dns zone + +Provides functionality for DNS zones + +### Synopsis + +Provides functionality for DNS zones + +``` +stackit dns zone [flags] +``` + +### Options + +``` + -h, --help Help for "stackit dns zone" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit dns](./stackit_dns.md) - Provides functionality for DNS +* [stackit dns zone create](./stackit_dns_zone_create.md) - Creates a DNS zone +* [stackit dns zone delete](./stackit_dns_zone_delete.md) - Delete a DNS zone +* [stackit dns zone describe](./stackit_dns_zone_describe.md) - Get details of a DNS zone +* [stackit dns zone list](./stackit_dns_zone_list.md) - List DNS zones +* [stackit dns zone update](./stackit_dns_zone_update.md) - Updates a DNS zone + diff --git a/docs/stackit_dns_zone_create.md b/docs/stackit_dns_zone_create.md new file mode 100644 index 00000000..80e70b7b --- /dev/null +++ b/docs/stackit_dns_zone_create.md @@ -0,0 +1,54 @@ +## stackit dns zone create + +Creates a DNS zone + +### Synopsis + +Creates a DNS zone + +``` +stackit dns zone create [flags] +``` + +### Examples + +``` + Create a DNS zone with name "my-zone" and DNS name "www.my-zone.com" + $ stackit dns zone create --name my-zone --dns-name www.my-zone.com + + Create a DNS zone with name "my-zone", DNS name "www.my-zone.com" and default time to live of 1000ms + $ stackit dns zone create --name my-zone --dns-name www.my-zone.com --default-ttl 1000 +``` + +### Options + +``` + --acl string Access control list + --contact-email string Contact email for the zone + --default-ttl int Default time to live (default 1000) + --description string Description of the zone + --dns-name string Fully qualified domain name of the DNS zone + --expire-time int Expire time + -h, --help Help for "stackit dns zone create" + --is-reverse-zone Is reverse zone + --name string User given name of the zone + --negative-cache int Negative cache + --primary strings Primary name server for secondary zone + --refresh-time int Refresh time + --retry-time int Retry time + --type string Zone type +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit dns zone](./stackit_dns_zone.md) - Provides functionality for DNS zones + diff --git a/docs/stackit_dns_zone_delete.md b/docs/stackit_dns_zone_delete.md new file mode 100644 index 00000000..bac777b7 --- /dev/null +++ b/docs/stackit_dns_zone_delete.md @@ -0,0 +1,38 @@ +## stackit dns zone delete + +Delete a DNS zone + +### Synopsis + +Delete a DNS zone + +``` +stackit dns zone delete ZONE_ID [flags] +``` + +### Examples + +``` + Delete a DNS zone with ID "xxx" + $ stackit dns zone delete xxx +``` + +### Options + +``` + -h, --help Help for "stackit dns zone delete" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit dns zone](./stackit_dns_zone.md) - Provides functionality for DNS zones + diff --git a/docs/stackit_dns_zone_describe.md b/docs/stackit_dns_zone_describe.md new file mode 100644 index 00000000..d2fb7942 --- /dev/null +++ b/docs/stackit_dns_zone_describe.md @@ -0,0 +1,41 @@ +## stackit dns zone describe + +Get details of a DNS zone + +### Synopsis + +Get details of a DNS zone + +``` +stackit dns zone describe ZONE_ID [flags] +``` + +### Examples + +``` + Get details of a DNS zone with ID "xxx" + $ stackit dns zone describe xxx + + Get details of a DNS zone with ID "xxx" in a table format + $ stackit dns zone describe xxx --output-format pretty +``` + +### Options + +``` + -h, --help Help for "stackit dns zone describe" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit dns zone](./stackit_dns_zone.md) - Provides functionality for DNS zones + diff --git a/docs/stackit_dns_zone_list.md b/docs/stackit_dns_zone_list.md new file mode 100644 index 00000000..6535651f --- /dev/null +++ b/docs/stackit_dns_zone_list.md @@ -0,0 +1,54 @@ +## stackit dns zone list + +List DNS zones + +### Synopsis + +List DNS zones. Successfully deleted zones are not listed by default. + +``` +stackit dns zone list [flags] +``` + +### Examples + +``` + List DNS zones + $ stackit dns zone list + + List DNS zones in JSON format + $ stackit dns zone list --output-format json + + List up to 10 DNS zones + $ stackit dns zone list --limit 10 + + List the deleted DNS zones + $ stackit dns zone list --deleted +``` + +### Options + +``` + --active Filter for active zones + --deleted Filter for deleted zones + -h, --help Help for "stackit dns zone list" + --inactive Filter for inactive zones. Deleted zones are always inactive and will be included when this flag is set + --limit int Maximum number of entries to list + --name-like string Filter by name + --order-by-name string Order by name, one of ["asc" "desc"] + --page-size int Number of items fetched in each API call. Does not affect the number of items in the command output (default 100) +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit dns zone](./stackit_dns_zone.md) - Provides functionality for DNS zones + diff --git a/docs/stackit_dns_zone_update.md b/docs/stackit_dns_zone_update.md new file mode 100644 index 00000000..e40e64ab --- /dev/null +++ b/docs/stackit_dns_zone_update.md @@ -0,0 +1,48 @@ +## stackit dns zone update + +Updates a DNS zone + +### Synopsis + +Updates a DNS zone. Performs a partial update; fields not provided are kept unchanged + +``` +stackit dns zone update ZONE_ID [flags] +``` + +### Examples + +``` + Update the contact email of the DNS zone with ID "xxx" + $ stackit dns zone update xxx --contact-email someone@domain.com +``` + +### Options + +``` + --acl string Access control list + --contact-email string Contact email for the zone + --default-ttl int Default time to live (default 1000) + --description string Description of the zone + --expire-time int Expire time + -h, --help Help for "stackit dns zone update" + --name string User given name of the zone + --negative-cache int Negative cache + --primary strings Primary name server for secondary zone + --refresh-time int Refresh time + --retry-time int Retry time +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit dns zone](./stackit_dns_zone.md) - Provides functionality for DNS zones + diff --git a/docs/stackit_mongodbflex.md b/docs/stackit_mongodbflex.md new file mode 100644 index 00000000..4ee04b90 --- /dev/null +++ b/docs/stackit_mongodbflex.md @@ -0,0 +1,34 @@ +## stackit mongodbflex + +Provides functionality for MongoDB Flex + +### Synopsis + +Provides functionality for MongoDB Flex + +``` +stackit mongodbflex [flags] +``` + +### Options + +``` + -h, --help Help for "stackit mongodbflex" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit](./stackit.md) - Manage STACKIT resources using the command line +* [stackit mongodbflex instance](./stackit_mongodbflex_instance.md) - Provides functionality for MongoDB Flex instances +* [stackit mongodbflex options](./stackit_mongodbflex_options.md) - List MongoDB Flex options +* [stackit mongodbflex user](./stackit_mongodbflex_user.md) - Provides functionality for MongoDB Flex users + diff --git a/docs/stackit_mongodbflex_instance.md b/docs/stackit_mongodbflex_instance.md new file mode 100644 index 00000000..c3451577 --- /dev/null +++ b/docs/stackit_mongodbflex_instance.md @@ -0,0 +1,36 @@ +## stackit mongodbflex instance + +Provides functionality for MongoDB Flex instances + +### Synopsis + +Provides functionality for MongoDB Flex instances + +``` +stackit mongodbflex instance [flags] +``` + +### Options + +``` + -h, --help Help for "stackit mongodbflex instance" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit mongodbflex](./stackit_mongodbflex.md) - Provides functionality for MongoDB Flex +* [stackit mongodbflex instance create](./stackit_mongodbflex_instance_create.md) - Create a MongoDB Flex instance +* [stackit mongodbflex instance delete](./stackit_mongodbflex_instance_delete.md) - Delete a MongoDB Flex instance +* [stackit mongodbflex instance describe](./stackit_mongodbflex_instance_describe.md) - Get details of a MongoDB Flex instance +* [stackit mongodbflex instance list](./stackit_mongodbflex_instance_list.md) - List all MongoDB Flex instances +* [stackit mongodbflex instance update](./stackit_mongodbflex_instance_update.md) - Update a MongoDB Flex instance + diff --git a/docs/stackit_mongodbflex_instance_create.md b/docs/stackit_mongodbflex_instance_create.md new file mode 100644 index 00000000..a2b3824c --- /dev/null +++ b/docs/stackit_mongodbflex_instance_create.md @@ -0,0 +1,54 @@ +## stackit mongodbflex instance create + +Create a MongoDB Flex instance + +### Synopsis + +Create a MongoDB Flex instance. + +``` +stackit mongodbflex instance create [flags] +``` + +### Examples + +``` + Create a MongoDB Flex instance with name "my-instance", ACL 0.0.0.0/0 (open access) and specify flavor by CPU and RAM. Other parameters are set to default values + $ stackit mongodbflex instance create --name my-instance --cpu 1 --ram 4 --acl 0.0.0.0/0 + + Create a MongoDB Flex instance with name "my-instance", ACL 0.0.0.0/0 (open access) and specify flavor by ID. Other parameters are set to default values + $ stackit mongodbflex instance create --name my-instance --flavor-id xxx --acl 0.0.0.0/0 + + Create a MongoDB Flex instance with name "my-instance", allow access to a specific range of IP addresses, specify flavor by CPU and RAM and set storage size to 20 GB. Other parameters are set to default values + $ stackit mongodbflex instance create --name my-instance --cpu 1 --ram 4 --acl 1.2.3.0/24 --storage-size 20 +``` + +### Options + +``` + --acl strings The access control list (ACL). Must contain at least one valid subnet, for instance '0.0.0.0/0' for open access (discouraged), '1.2.3.0/24 for a public IP range of an organization, '1.2.3.4/32' for a single IP range, etc. (default []) + --backup-schedule string Backup schedule (default "0 0/6 * * *") + --cpu int Number of CPUs + --flavor-id string ID of the flavor + -h, --help Help for "stackit mongodbflex instance create" + -n, --name string Instance name + --ram int Amount of RAM (in GB) + --storage-class string Storage class (default "premium-perf2-mongodb") + --storage-size int Storage size (in GB) (default 10) + --type string Instance type, one of ["Single" "Replica" "Sharded"] (default "Replica") + --version string Version (default "6.0") +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit mongodbflex instance](./stackit_mongodbflex_instance.md) - Provides functionality for MongoDB Flex instances + diff --git a/docs/stackit_mongodbflex_instance_delete.md b/docs/stackit_mongodbflex_instance_delete.md new file mode 100644 index 00000000..01f1f866 --- /dev/null +++ b/docs/stackit_mongodbflex_instance_delete.md @@ -0,0 +1,38 @@ +## stackit mongodbflex instance delete + +Delete a MongoDB Flex instance + +### Synopsis + +Delete a MongoDB Flex instance + +``` +stackit mongodbflex instance delete INSTANCE_ID [flags] +``` + +### Examples + +``` + Delete a MongoDB Flex instance with ID "xxx" + $ stackit mongodbflex instance delete xxx +``` + +### Options + +``` + -h, --help Help for "stackit mongodbflex instance delete" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit mongodbflex instance](./stackit_mongodbflex_instance.md) - Provides functionality for MongoDB Flex instances + diff --git a/docs/stackit_mongodbflex_instance_describe.md b/docs/stackit_mongodbflex_instance_describe.md new file mode 100644 index 00000000..9375247f --- /dev/null +++ b/docs/stackit_mongodbflex_instance_describe.md @@ -0,0 +1,41 @@ +## stackit mongodbflex instance describe + +Get details of a MongoDB Flex instance + +### Synopsis + +Get details of a MongoDB Flex instance + +``` +stackit mongodbflex instance describe INSTANCE_ID [flags] +``` + +### Examples + +``` + Get details of a MongoDB Flex instance with ID "xxx" + $ stackit mongodbflex instance describe xxx + + Get details of a MongoDB Flex instance with ID "xxx" in a table format + $ stackit mongodbflex instance describe xxx --output-format pretty +``` + +### Options + +``` + -h, --help Help for "stackit mongodbflex instance describe" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit mongodbflex instance](./stackit_mongodbflex_instance.md) - Provides functionality for MongoDB Flex instances + diff --git a/docs/stackit_mongodbflex_instance_list.md b/docs/stackit_mongodbflex_instance_list.md new file mode 100644 index 00000000..bae2492d --- /dev/null +++ b/docs/stackit_mongodbflex_instance_list.md @@ -0,0 +1,45 @@ +## stackit mongodbflex instance list + +List all MongoDB Flex instances + +### Synopsis + +List all MongoDB Flex instances + +``` +stackit mongodbflex instance list [flags] +``` + +### Examples + +``` + List all MongoDB Flex instances + $ stackit mongodbflex instance list + + List all MongoDB Flex instances in JSON format + $ stackit mongodbflex instance list --output-format json + + List up to 10 MongoDB Flex instances + $ stackit mongodbflex instance list --limit 10 +``` + +### Options + +``` + -h, --help Help for "stackit mongodbflex instance list" + --limit int Maximum number of entries to list +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit mongodbflex instance](./stackit_mongodbflex_instance.md) - Provides functionality for MongoDB Flex instances + diff --git a/docs/stackit_mongodbflex_instance_update.md b/docs/stackit_mongodbflex_instance_update.md new file mode 100644 index 00000000..7b3f20e4 --- /dev/null +++ b/docs/stackit_mongodbflex_instance_update.md @@ -0,0 +1,52 @@ +## stackit mongodbflex instance update + +Update a MongoDB Flex instance + +### Synopsis + +Update a MongoDB Flex instance. + +``` +stackit mongodbflex instance update INSTANCE_ID [flags] +``` + +### Examples + +``` + Update the name of a MongoDB Flex instance + $ stackit mongodbflex instance update xxx --name my-new-name + + Update the version of a MongoDB Flex instance + $ stackit mongodbflex instance update xxx --version 6.0 +``` + +### Options + +``` + --acl strings List of IP networks in CIDR notation which are allowed to access this instance (default []) + --backup-schedule string Backup schedule + --cpu int Number of CPUs + --flavor-id string ID of the flavor + -h, --help Help for "stackit mongodbflex instance update" + -n, --name string Instance name + --ram int Amount of RAM (in GB) + --replicas int Number of replicas + --storage-class string Storage class + --storage-size int Storage size (in GB) + --type string Instance type, one of ["Single" "Replica" "Sharded"] + --version string Version +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit mongodbflex instance](./stackit_mongodbflex_instance.md) - Provides functionality for MongoDB Flex instances + diff --git a/docs/stackit_mongodbflex_options.md b/docs/stackit_mongodbflex_options.md new file mode 100644 index 00000000..541482f8 --- /dev/null +++ b/docs/stackit_mongodbflex_options.md @@ -0,0 +1,49 @@ +## stackit mongodbflex options + +List MongoDB Flex options + +### Synopsis + +List MongoDB Flex options (flavors, versions and storages for a given flavor) +Pass one or more flags to filter what categories are shown. + +``` +stackit mongodbflex options [flags] +``` + +### Examples + +``` + List MongoDB Flex flavors options + $ stackit mongodbflex options --flavors + + List MongoDB Flex available versions + $ stackit mongodbflex options --versions + + List MongoDB Flex storage options for a given flavor. The flavor ID can be retrieved by running "$ stackit mongodbflex options --flavors" + $ stackit mongodbflex options --storages --flavor-id +``` + +### Options + +``` + --flavor-id string The flavor ID to show storages for. Only relevant when "--storages" is passed + --flavors Lists supported flavors + -h, --help Help for "stackit mongodbflex options" + --storages Lists supported storages for a given flavor + --versions Lists supported versions +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit mongodbflex](./stackit_mongodbflex.md) - Provides functionality for MongoDB Flex + diff --git a/docs/stackit_mongodbflex_user.md b/docs/stackit_mongodbflex_user.md new file mode 100644 index 00000000..f27a8c59 --- /dev/null +++ b/docs/stackit_mongodbflex_user.md @@ -0,0 +1,37 @@ +## stackit mongodbflex user + +Provides functionality for MongoDB Flex users + +### Synopsis + +Provides functionality for MongoDB Flex users + +``` +stackit mongodbflex user [flags] +``` + +### Options + +``` + -h, --help Help for "stackit mongodbflex user" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit mongodbflex](./stackit_mongodbflex.md) - Provides functionality for MongoDB Flex +* [stackit mongodbflex user create](./stackit_mongodbflex_user_create.md) - Create a MongoDB Flex user +* [stackit mongodbflex user delete](./stackit_mongodbflex_user_delete.md) - Delete a MongoDB Flex user +* [stackit mongodbflex user describe](./stackit_mongodbflex_user_describe.md) - Get details of a MongoDB Flex user +* [stackit mongodbflex user list](./stackit_mongodbflex_user_list.md) - List all MongoDB Flex users of an instance +* [stackit mongodbflex user reset-password](./stackit_mongodbflex_user_reset-password.md) - Reset the password of a MongoDB Flex user +* [stackit mongodbflex user update](./stackit_mongodbflex_user_update.md) - Update a MongoDB Flex user + diff --git a/docs/stackit_mongodbflex_user_create.md b/docs/stackit_mongodbflex_user_create.md new file mode 100644 index 00000000..1f8fe80b --- /dev/null +++ b/docs/stackit_mongodbflex_user_create.md @@ -0,0 +1,48 @@ +## stackit mongodbflex user create + +Create a MongoDB Flex user + +### Synopsis + +Create a MongoDB Flex user. +The password is only visible upon creation and cannot be retrieved later. +Alternatively, you can reset the password and access the new one by running: + $ stackit mongodbflex user reset-password --instance-id --user-id + +``` +stackit mongodbflex user create [flags] +``` + +### Examples + +``` + Create a MongoDB Flex user for instance with ID "xxx" and specify the username + $ stackit mongodbflex user create --instance-id xxx --username johndoe --roles read --database default + + Create a MongoDB Flex user for instance with ID "xxx" with an automatically generated username + $ stackit mongodbflex user create --instance-id xxx --roles read --database default +``` + +### Options + +``` + --database string The database inside the MongoDB instance that the user has access to. If it does not exist, it will be created once the user writes to it + -h, --help Help for "stackit mongodbflex user create" + --instance-id string ID of the instance + --roles strings Roles of the user, possible values are ["read" "readWrite"] (default [read]) + --username string Username of the user. If not specified, a random username will be assigned +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit mongodbflex user](./stackit_mongodbflex_user.md) - Provides functionality for MongoDB Flex users + diff --git a/docs/stackit_mongodbflex_user_delete.md b/docs/stackit_mongodbflex_user_delete.md new file mode 100644 index 00000000..ca84d816 --- /dev/null +++ b/docs/stackit_mongodbflex_user_delete.md @@ -0,0 +1,40 @@ +## stackit mongodbflex user delete + +Delete a MongoDB Flex user + +### Synopsis + +Delete a MongoDB Flex user by ID. You can get the IDs of users for an instance by running: + $ stackit mongodbflex user list --instance-id + +``` +stackit mongodbflex user delete USER_ID [flags] +``` + +### Examples + +``` + Delete a MongoDB Flex user with ID "xxx" for instance with ID "yyy" + $ stackit mongodbflex user delete xxx --instance-id yyy +``` + +### Options + +``` + -h, --help Help for "stackit mongodbflex user delete" + --instance-id string Instance ID +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit mongodbflex user](./stackit_mongodbflex_user.md) - Provides functionality for MongoDB Flex users + diff --git a/docs/stackit_mongodbflex_user_describe.md b/docs/stackit_mongodbflex_user_describe.md new file mode 100644 index 00000000..fc08a17c --- /dev/null +++ b/docs/stackit_mongodbflex_user_describe.md @@ -0,0 +1,44 @@ +## stackit mongodbflex user describe + +Get details of a MongoDB Flex user + +### Synopsis + +Get details of a MongoDB Flex user. +The user password is hidden inside the "host" field and replaced with asterisks, as it is only visible upon creation. You can reset it by running: + $ stackit mongodbflex user reset-password --instance-id + +``` +stackit mongodbflex user describe USER_ID [flags] +``` + +### Examples + +``` + Get details of a MongoDB Flex user with ID "xxx" of instance with ID "yyy" + $ stackit mongodbflex user list xxx --instance-id yyy + + Get details of a MongoDB Flex user with ID "xxx" of instance with ID "xxx" in table format + $ stackit mongodbflex user list xxx --instance-id yyy --output-format pretty +``` + +### Options + +``` + -h, --help Help for "stackit mongodbflex user describe" + --instance-id string ID of the instance +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit mongodbflex user](./stackit_mongodbflex_user.md) - Provides functionality for MongoDB Flex users + diff --git a/docs/stackit_mongodbflex_user_list.md b/docs/stackit_mongodbflex_user_list.md new file mode 100644 index 00000000..cefb854a --- /dev/null +++ b/docs/stackit_mongodbflex_user_list.md @@ -0,0 +1,46 @@ +## stackit mongodbflex user list + +List all MongoDB Flex users of an instance + +### Synopsis + +List all MongoDB Flex users of an instance. + +``` +stackit mongodbflex user list [flags] +``` + +### Examples + +``` + List all MongoDB Flex users of instance with ID "xxx" + $ stackit mongodbflex user list --instance-id xxx + + List all MongoDB Flex users of instance with ID "xxx" in JSON format + $ stackit mongodbflex user list --instance-id xxx --output-format json + + List up to 10 MongoDB Flex users of instance with ID "xxx" + $ stackit mongodbflex user list --instance-id xxx --limit 10 +``` + +### Options + +``` + -h, --help Help for "stackit mongodbflex user list" + --instance-id string Instance ID + --limit int Maximum number of entries to list +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit mongodbflex user](./stackit_mongodbflex_user.md) - Provides functionality for MongoDB Flex users + diff --git a/docs/stackit_mongodbflex_user_reset-password.md b/docs/stackit_mongodbflex_user_reset-password.md new file mode 100644 index 00000000..1daa927a --- /dev/null +++ b/docs/stackit_mongodbflex_user_reset-password.md @@ -0,0 +1,39 @@ +## stackit mongodbflex user reset-password + +Reset the password of a MongoDB Flex user + +### Synopsis + +Reset the password of a MongoDB Flex user. The new password is returned in the response. + +``` +stackit mongodbflex user reset-password USER_ID [flags] +``` + +### Examples + +``` + Reset the password of a MongoDB Flex user with ID "xxx" of instance with ID "yyy" + $ stackit mongodbflex user reset-password xxx --instance-id yyy +``` + +### Options + +``` + -h, --help Help for "stackit mongodbflex user reset-password" + --instance-id string ID of the instance +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit mongodbflex user](./stackit_mongodbflex_user.md) - Provides functionality for MongoDB Flex users + diff --git a/docs/stackit_mongodbflex_user_update.md b/docs/stackit_mongodbflex_user_update.md new file mode 100644 index 00000000..45ab8a36 --- /dev/null +++ b/docs/stackit_mongodbflex_user_update.md @@ -0,0 +1,41 @@ +## stackit mongodbflex user update + +Update a MongoDB Flex user + +### Synopsis + +Update a MongoDB Flex user. + +``` +stackit mongodbflex user update USER_ID [flags] +``` + +### Examples + +``` + Update the roles of a MongoDB Flex user with ID "xxx" of instance with ID "yyy" + $ stackit mongodbflex user update xxx --instance-id yyy --roles read +``` + +### Options + +``` + --database string The database inside the MongoDB instance that the user has access to. If it does not exist, it will be created once the user writes to it + -h, --help Help for "stackit mongodbflex user update" + --instance-id string ID of the instance + --roles strings Roles of the user, possible values are ["read" "readWrite"] (default []) +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit mongodbflex user](./stackit_mongodbflex_user.md) - Provides functionality for MongoDB Flex users + diff --git a/docs/stackit_organization.md b/docs/stackit_organization.md new file mode 100644 index 00000000..fcdf5337 --- /dev/null +++ b/docs/stackit_organization.md @@ -0,0 +1,34 @@ +## stackit organization + +Provides functionality regarding organizations + +### Synopsis + +Provides functionality regarding organizations. +An active STACKIT organization is the root element of the resource hierarchy and a prerequisite to use any STACKIT Cloud Resource / Service + +``` +stackit organization [flags] +``` + +### Options + +``` + -h, --help Help for "stackit organization" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit](./stackit.md) - Manage STACKIT resources using the command line +* [stackit organization member](./stackit_organization_member.md) - Provides functionality regarding organization members +* [stackit organization role](./stackit_organization_role.md) - Provides functionality regarding organization roles + diff --git a/docs/stackit_organization_member.md b/docs/stackit_organization_member.md new file mode 100644 index 00000000..bd1282e3 --- /dev/null +++ b/docs/stackit_organization_member.md @@ -0,0 +1,34 @@ +## stackit organization member + +Provides functionality regarding organization members + +### Synopsis + +Provides functionality regarding organization members + +``` +stackit organization member [flags] +``` + +### Options + +``` + -h, --help Help for "stackit organization member" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit organization](./stackit_organization.md) - Provides functionality regarding organizations +* [stackit organization member add](./stackit_organization_member_add.md) - Add a member to an organization +* [stackit organization member list](./stackit_organization_member_list.md) - List members of an organization +* [stackit organization member remove](./stackit_organization_member_remove.md) - Remove a member from an organization. + diff --git a/docs/stackit_organization_member_add.md b/docs/stackit_organization_member_add.md new file mode 100644 index 00000000..a9f1cddf --- /dev/null +++ b/docs/stackit_organization_member_add.md @@ -0,0 +1,44 @@ +## stackit organization member add + +Add a member to an organization + +### Synopsis + +Add a member to an organization. +A member is a combination of a subject (user, service account or client) and a role. +The subject is usually email address for users or name in case of clients +For more details on the available roles, run: + $ stackit organization role list --organization-id + +``` +stackit organization member add SUBJECT [flags] +``` + +### Examples + +``` + Add a member to an organization with the "reader" role + $ stackit organization member add someone@domain.com --organization-id xxx --role reader +``` + +### Options + +``` + -h, --help Help for "stackit organization member add" + --organization-id string The organization ID + --role string The role to add to the subject +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit organization member](./stackit_organization_member.md) - Provides functionality regarding organization members + diff --git a/docs/stackit_organization_member_list.md b/docs/stackit_organization_member_list.md new file mode 100644 index 00000000..39313cd0 --- /dev/null +++ b/docs/stackit_organization_member_list.md @@ -0,0 +1,48 @@ +## stackit organization member list + +List members of an organization + +### Synopsis + +List members of an organization + +``` +stackit organization member list [flags] +``` + +### Examples + +``` + List all members of an organization + $ stackit organization role list --organization-id xxx + + List all members of an organization in JSON format + $ stackit organization role list --organization-id xxx --output-format json + + List up to 10 members of an organization + $ stackit organization role list --organization-id xxx --limit 10 +``` + +### Options + +``` + -h, --help Help for "stackit organization member list" + --limit int Maximum number of entries to list + --organization-id string The organization ID + --sort-by string Sort entries by a specific field, one of ["subject" "role"] (default "subject") + --subject string Filter by subject (Identifier of user, service account or client. Usually email address in case of users or name in case of clients) +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit organization member](./stackit_organization_member.md) - Provides functionality regarding organization members + diff --git a/docs/stackit_organization_member_remove.md b/docs/stackit_organization_member_remove.md new file mode 100644 index 00000000..e8b00033 --- /dev/null +++ b/docs/stackit_organization_member_remove.md @@ -0,0 +1,46 @@ +## stackit organization member remove + +Remove a member from an organization. + +### Synopsis + +Remove a member from an organization. +A member is a combination of a subject (user, service account or client) and a role. +The subject is usually email address for users or name in case of clients + +``` +stackit organization member remove SUBJECT [flags] +``` + +### Examples + +``` + Remove a member (user "someone@domain.com" with an "editor" role) from an organization + $ stackit organization member remove someone@domain.com --organization-id xxx --role editor + + Remove a member (user "someone@domain.com" with a "reader" role) from an organization, along with all other roles of the subject that would stop the removal of the "reader" role + $ stackit organization member remove someone@domain.com --organization-id xxx --role reader --force +``` + +### Options + +``` + --force When true, removes other roles of the subject that would stop the removal of the requested role + -h, --help Help for "stackit organization member remove" + --organization-id string The organization ID + --role string The role to be removed from the subject +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit organization member](./stackit_organization_member.md) - Provides functionality regarding organization members + diff --git a/docs/stackit_organization_role.md b/docs/stackit_organization_role.md new file mode 100644 index 00000000..afd377b1 --- /dev/null +++ b/docs/stackit_organization_role.md @@ -0,0 +1,32 @@ +## stackit organization role + +Provides functionality regarding organization roles + +### Synopsis + +Provides functionality regarding organization roles + +``` +stackit organization role [flags] +``` + +### Options + +``` + -h, --help Help for "stackit organization role" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit organization](./stackit_organization.md) - Provides functionality regarding organizations +* [stackit organization role list](./stackit_organization_role_list.md) - List roles and permissions of an organization + diff --git a/docs/stackit_organization_role_list.md b/docs/stackit_organization_role_list.md new file mode 100644 index 00000000..b9f74d93 --- /dev/null +++ b/docs/stackit_organization_role_list.md @@ -0,0 +1,46 @@ +## stackit organization role list + +List roles and permissions of an organization + +### Synopsis + +List roles and permissions of an organization + +``` +stackit organization role list [flags] +``` + +### Examples + +``` + List all roles and permissions of an organization + $ stackit organization role list --organization-id xxx + + List all roles and permissions of an organization in JSON format + $ stackit organization role list --organization-id xxx --output-format json + + List up to 10 roles and permissions of an organization + $ stackit organization role list --organization-id xxx --limit 10 +``` + +### Options + +``` + -h, --help Help for "stackit organization role list" + --limit int Maximum number of entries to list + --organization-id string Organization ID +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit organization role](./stackit_organization_role.md) - Provides functionality regarding organization roles + diff --git a/docs/stackit_project.md b/docs/stackit_project.md new file mode 100644 index 00000000..3e70b360 --- /dev/null +++ b/docs/stackit_project.md @@ -0,0 +1,39 @@ +## stackit project + +Provides functionality regarding projects + +### Synopsis + +Provides functionality regarding projects. +A project is a container for resources which is the service that you can purchase from STACKIT. + +``` +stackit project [flags] +``` + +### Options + +``` + -h, --help Help for "stackit project" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit](./stackit.md) - Manage STACKIT resources using the command line +* [stackit project create](./stackit_project_create.md) - Create STACKIT projects +* [stackit project delete](./stackit_project_delete.md) - Delete a STACKIT project +* [stackit project describe](./stackit_project_describe.md) - Get the details of a STACKIT project +* [stackit project list](./stackit_project_list.md) - List STACKIT projects +* [stackit project member](./stackit_project_member.md) - Provides functionality regarding project members +* [stackit project role](./stackit_project_role.md) - Provides functionality regarding project roles +* [stackit project update](./stackit_project_update.md) - Update a STACKIT project + diff --git a/docs/stackit_project_create.md b/docs/stackit_project_create.md new file mode 100644 index 00000000..ba87bf64 --- /dev/null +++ b/docs/stackit_project_create.md @@ -0,0 +1,44 @@ +## stackit project create + +Create STACKIT projects + +### Synopsis + +Create STACKIT projects + +``` +stackit project create [flags] +``` + +### Examples + +``` + Create a STACKIT project + $ stackit project create --parent-id xxxx --name my-project + + Create a STACKIT project with a set of labels + $ stackit project create --parent-id xxxx --name my-project --label key=value --label foo=bar +``` + +### Options + +``` + -h, --help Help for "stackit project create" + --label stringToString Labels are key-value string pairs which can be attached to a project. A label can be provided with the format key=value and the flag can be used multiple times to provide a list of labels (default []) + --name string Project name + --parent-id string Parent resource identifier. Both container ID (user-friendly) and UUID are supported +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit project](./stackit_project.md) - Provides functionality regarding projects + diff --git a/docs/stackit_project_delete.md b/docs/stackit_project_delete.md new file mode 100644 index 00000000..5e8e63e8 --- /dev/null +++ b/docs/stackit_project_delete.md @@ -0,0 +1,41 @@ +## stackit project delete + +Delete a STACKIT project + +### Synopsis + +Delete a STACKIT project + +``` +stackit project delete [flags] +``` + +### Examples + +``` + Delete the configured STACKIT project + $ stackit project delete + + Delete a STACKIT project by explicitly providing the project ID + $ stackit project delete --project-id xxx +``` + +### Options + +``` + -h, --help Help for "stackit project delete" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit project](./stackit_project.md) - Provides functionality regarding projects + diff --git a/docs/stackit_project_describe.md b/docs/stackit_project_describe.md new file mode 100644 index 00000000..1c40262a --- /dev/null +++ b/docs/stackit_project_describe.md @@ -0,0 +1,45 @@ +## stackit project describe + +Get the details of a STACKIT project + +### Synopsis + +Get the details of a STACKIT project + +``` +stackit project describe [flags] +``` + +### Examples + +``` + Get the details of the configured STACKIT project + $ stackit project describe + + Get the details of a STACKIT project by explicitly providing the project ID + $ stackit project describe --project-id xxx + + Get the details of the configured STACKIT project, including details of the parent resources + $ stackit project describe --include-parents +``` + +### Options + +``` + -h, --help Help for "stackit project describe" + --include-parents When true, the details of the parent resources will be included in the output +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit project](./stackit_project.md) - Provides functionality regarding projects + diff --git a/docs/stackit_project_list.md b/docs/stackit_project_list.md new file mode 100644 index 00000000..f3dde491 --- /dev/null +++ b/docs/stackit_project_list.md @@ -0,0 +1,50 @@ +## stackit project list + +List STACKIT projects + +### Synopsis + +List all STACKIT projects that match certain criteria. At least one of parent-id, project-id-like or member flag must be provided + +``` +stackit project list [flags] +``` + +### Examples + +``` + List all STACKIT projects that are children of a specific parent + $ stackit project list --parent-id xxx + + List all STACKIT projects that match the given project IDs, located under the same parent resource + $ stackit project list --project-id-like xxx,yyy,zzz + + List all STACKIT projects that a certain user is a member of + $ stackit project list --member example@email.com +``` + +### Options + +``` + --creation-time-after string Filter by creation timestamp, in a date-time with the RFC3339 layout format, e.g. 2023-01-01T00:00:00Z. The list of projects that were created after the given timestamp will be shown + -h, --help Help for "stackit project list" + --limit int Maximum number of entries to list + --member string Filter by member. The list of projects of which the member is part of will be shown + --page-size int Number of items fetched in each API call. Does not affect the number of items in the command output (default 50) + --parent-id string Filter by parent identifier + --project-id-like strings Filter by project identifier. Multiple project IDs can be provided, but they need to belong to the same parent resource (default []) +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit project](./stackit_project.md) - Provides functionality regarding projects + diff --git a/docs/stackit_project_member.md b/docs/stackit_project_member.md new file mode 100644 index 00000000..a39a5c85 --- /dev/null +++ b/docs/stackit_project_member.md @@ -0,0 +1,34 @@ +## stackit project member + +Provides functionality regarding project members + +### Synopsis + +Provides functionality regarding project members + +``` +stackit project member [flags] +``` + +### Options + +``` + -h, --help Help for "stackit project member" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit project](./stackit_project.md) - Provides functionality regarding projects +* [stackit project member add](./stackit_project_member_add.md) - Add a member to a project +* [stackit project member list](./stackit_project_member_list.md) - List members of a project +* [stackit project member remove](./stackit_project_member_remove.md) - Remove a member from a project. + diff --git a/docs/stackit_project_member_add.md b/docs/stackit_project_member_add.md new file mode 100644 index 00000000..1009e638 --- /dev/null +++ b/docs/stackit_project_member_add.md @@ -0,0 +1,43 @@ +## stackit project member add + +Add a member to a project + +### Synopsis + +Add a member to a project. +A member is a combination of a subject (user, service account or client) and a role. +The subject is usually email address for users or name in case of clients +For more details on the available roles, run: + $ stackit project role list --project-id + +``` +stackit project member add SUBJECT [flags] +``` + +### Examples + +``` + Add a member to a project with the "reader" role + $ stackit project member add someone@domain.com --project-id xxx --role reader +``` + +### Options + +``` + -h, --help Help for "stackit project member add" + --role string The role to add to the subject +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit project member](./stackit_project_member.md) - Provides functionality regarding project members + diff --git a/docs/stackit_project_member_list.md b/docs/stackit_project_member_list.md new file mode 100644 index 00000000..2f5d24ce --- /dev/null +++ b/docs/stackit_project_member_list.md @@ -0,0 +1,47 @@ +## stackit project member list + +List members of a project + +### Synopsis + +List members of a project + +``` +stackit project member list [flags] +``` + +### Examples + +``` + List all members of a project + $ stackit project role list --project-id xxx + + List all members of a project, sorted by role + $ stackit project role list --project-id xxx --sort-by role + + List up to 10 members of a project + $ stackit project role list --project-id xxx --limit 10 +``` + +### Options + +``` + -h, --help Help for "stackit project member list" + --limit int Maximum number of entries to list + --sort-by string Sort entries by a specific field, one of ["subject" "role"] (default "subject") + --subject string Filter by subject (Identifier of user, service account or client. Usually email address in case of users or name in case of clients) +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit project member](./stackit_project_member.md) - Provides functionality regarding project members + diff --git a/docs/stackit_project_member_remove.md b/docs/stackit_project_member_remove.md new file mode 100644 index 00000000..989ea25f --- /dev/null +++ b/docs/stackit_project_member_remove.md @@ -0,0 +1,45 @@ +## stackit project member remove + +Remove a member from a project. + +### Synopsis + +Remove a member from a project. +A member is a combination of a subject (user, service account or client) and a role. +The subject is usually email address for users or name in case of clients + +``` +stackit project member remove SUBJECT [flags] +``` + +### Examples + +``` + Remove a member (user "someone@domain.com" with an "editor" role) from a project + $ stackit project member remove someone@domain.com --project-id xxx --role editor + + Remove a member (user "someone@domain.com" with a "reader" role) from a project, along with all other roles of the subject that would stop the removal of the "reader" role + $ stackit project member remove someone@domain.com --project-id xxx --role reader --force +``` + +### Options + +``` + --force When true, removes other roles of the subject that would stop the removal of the requested role + -h, --help Help for "stackit project member remove" + --role string The role to be removed from the subject +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit project member](./stackit_project_member.md) - Provides functionality regarding project members + diff --git a/docs/stackit_project_role.md b/docs/stackit_project_role.md new file mode 100644 index 00000000..ca58b4af --- /dev/null +++ b/docs/stackit_project_role.md @@ -0,0 +1,32 @@ +## stackit project role + +Provides functionality regarding project roles + +### Synopsis + +Provides functionality regarding project roles + +``` +stackit project role [flags] +``` + +### Options + +``` + -h, --help Help for "stackit project role" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit project](./stackit_project.md) - Provides functionality regarding projects +* [stackit project role list](./stackit_project_role_list.md) - List roles and permissions of a project + diff --git a/docs/stackit_project_role_list.md b/docs/stackit_project_role_list.md new file mode 100644 index 00000000..d06f21c0 --- /dev/null +++ b/docs/stackit_project_role_list.md @@ -0,0 +1,45 @@ +## stackit project role list + +List roles and permissions of a project + +### Synopsis + +List roles and permissions of a project + +``` +stackit project role list [flags] +``` + +### Examples + +``` + List all roles and permissions of a project + $ stackit project role list --project-id xxx + + List all roles and permissions of a project in JSON format + $ stackit project role list --project-id xxx --output-format json + + List up to 10 roles and permissions of a project + $ stackit project role list --project-id xxx --limit 10 +``` + +### Options + +``` + -h, --help Help for "stackit project role list" + --limit int Maximum number of entries to list +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit project role](./stackit_project_role.md) - Provides functionality regarding project roles + diff --git a/docs/stackit_project_update.md b/docs/stackit_project_update.md new file mode 100644 index 00000000..7cfcf49c --- /dev/null +++ b/docs/stackit_project_update.md @@ -0,0 +1,47 @@ +## stackit project update + +Update a STACKIT project + +### Synopsis + +Update a STACKIT project + +``` +stackit project update [flags] +``` + +### Examples + +``` + Update the name of the configured STACKIT project + $ stackit project update --name my-updated-project + + Add labels to the configured STACKIT project + $ stackit project update --label key=value,foo=bar + + Update the name of a STACKIT project by explicitly providing the project ID + $ stackit project update --name my-updated-project --project-id xxx +``` + +### Options + +``` + -h, --help Help for "stackit project update" + --label stringToString Labels are key-value string pairs which can be attached to a project. A label can be provided with the format key=value and the flag can be used multiple times to provide a list of labels (default []) + --name string Project name + --parent-id string Parent resource identifier. Both container ID (user-friendly) and UUID are supported +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit project](./stackit_project.md) - Provides functionality regarding projects + diff --git a/docs/stackit_service-account.md b/docs/stackit_service-account.md new file mode 100644 index 00000000..9372aefd --- /dev/null +++ b/docs/stackit_service-account.md @@ -0,0 +1,37 @@ +## stackit service-account + +Provides functionality for service accounts + +### Synopsis + +Provides functionality for service accounts + +``` +stackit service-account [flags] +``` + +### Options + +``` + -h, --help Help for "stackit service-account" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit](./stackit.md) - Manage STACKIT resources using the command line +* [stackit service-account create](./stackit_service-account_create.md) - Create a service account +* [stackit service-account delete](./stackit_service-account_delete.md) - Delete a service account +* [stackit service-account get-jwks](./stackit_service-account_get-jwks.md) - Get JWKS for a service account +* [stackit service-account key](./stackit_service-account_key.md) - Provides functionality regarding service account keys +* [stackit service-account list](./stackit_service-account_list.md) - List all service accounts +* [stackit service-account token](./stackit_service-account_token.md) - Provides functionality regarding service account tokens + diff --git a/docs/stackit_service-account_create.md b/docs/stackit_service-account_create.md new file mode 100644 index 00000000..659e41f2 --- /dev/null +++ b/docs/stackit_service-account_create.md @@ -0,0 +1,39 @@ +## stackit service-account create + +Create a service account + +### Synopsis + +Create a service account + +``` +stackit service-account create [flags] +``` + +### Examples + +``` + Create a service account with name "my-service-account" + $ stackit service-account create --name my-service-account +``` + +### Options + +``` + -h, --help Help for "stackit service-account create" + -n, --name string Service account name. A unique email will be generated from this name +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit service-account](./stackit_service-account.md) - Provides functionality for service accounts + diff --git a/docs/stackit_service-account_delete.md b/docs/stackit_service-account_delete.md new file mode 100644 index 00000000..0a59e4f3 --- /dev/null +++ b/docs/stackit_service-account_delete.md @@ -0,0 +1,38 @@ +## stackit service-account delete + +Delete a service account + +### Synopsis + +Delete a service account + +``` +stackit service-account delete EMAIL [flags] +``` + +### Examples + +``` + Delete a service account with email "my-service-account-1234567@sa.stackit.cloud" + $ stackit service-account delete my-service-account-1234567@sa.stackit.cloud +``` + +### Options + +``` + -h, --help Help for "stackit service-account delete" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit service-account](./stackit_service-account.md) - Provides functionality for service accounts + diff --git a/docs/stackit_service-account_get-jwks.md b/docs/stackit_service-account_get-jwks.md new file mode 100644 index 00000000..e208fa79 --- /dev/null +++ b/docs/stackit_service-account_get-jwks.md @@ -0,0 +1,38 @@ +## stackit service-account get-jwks + +Get JWKS for a service account + +### Synopsis + +Get JSON Web Key set (JWKS) for a service account. Only JSON output is supported + +``` +stackit service-account get-jwks EMAIL [flags] +``` + +### Examples + +``` + Get JWKS for the service account with email "my-service-account-1234567@sa.stackit.cloud" + $ stackit service-account get-jwks my-service-account-1234567@sa.stackit.cloud +``` + +### Options + +``` + -h, --help Help for "stackit service-account get-jwks" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit service-account](./stackit_service-account.md) - Provides functionality for service accounts + diff --git a/docs/stackit_service-account_key.md b/docs/stackit_service-account_key.md new file mode 100644 index 00000000..f93e5032 --- /dev/null +++ b/docs/stackit_service-account_key.md @@ -0,0 +1,36 @@ +## stackit service-account key + +Provides functionality regarding service account keys + +### Synopsis + +Provides functionality regarding service account keys + +``` +stackit service-account key [flags] +``` + +### Options + +``` + -h, --help Help for "stackit service-account key" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit service-account](./stackit_service-account.md) - Provides functionality for service accounts +* [stackit service-account key create](./stackit_service-account_key_create.md) - Create a service account key +* [stackit service-account key delete](./stackit_service-account_key_delete.md) - Delete a service account key +* [stackit service-account key describe](./stackit_service-account_key_describe.md) - Get details of a service account key +* [stackit service-account key list](./stackit_service-account_key_list.md) - List all service account keys +* [stackit service-account key update](./stackit_service-account_key_update.md) - Update a service account key + diff --git a/docs/stackit_service-account_key_create.md b/docs/stackit_service-account_key_create.md new file mode 100644 index 00000000..b8ebd193 --- /dev/null +++ b/docs/stackit_service-account_key_create.md @@ -0,0 +1,49 @@ +## stackit service-account key create + +Create a service account key + +### Synopsis + +Create a service account key. +You can generate an RSA keypair and provide the public key. +If you do not provide a public key, the service will generate a new key-pair and the private key is included in the response. You won't be able to retrieve it later. + +``` +stackit service-account key create [flags] +``` + +### Examples + +``` + Create a key for the service account with email "my-service-account-1234567@sa.stackit.cloud with no expiration date" + $ stackit service-account key create --email my-service-account-1234567@sa.stackit.cloud + + Create a key for the service account with email "my-service-account-1234567@sa.stackit.cloud" expiring in 10 days + $ stackit service-account key create --email my-service-account-1234567@sa.stackit.cloud --expires-in-days 10 + + Create a key for the service account with email "my-service-account-1234567@sa.stackit.cloud" and provide the public key in a .pem file" + $ stackit service-account key create --email my-service-account-1234567@sa.stackit.cloud --public-key @./public.pem +``` + +### Options + +``` + -e, --email string Service account email + --expires-in-days int Number of days until expiration. When omitted, the key is valid until deleted + -h, --help Help for "stackit service-account key create" + --public-key string Public key of the user generated RSA 2048 key-pair. Must be in x509 format. Can be a string or path to the .pem file, if prefixed with "@" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit service-account key](./stackit_service-account_key.md) - Provides functionality regarding service account keys + diff --git a/docs/stackit_service-account_key_delete.md b/docs/stackit_service-account_key_delete.md new file mode 100644 index 00000000..7c94bcc1 --- /dev/null +++ b/docs/stackit_service-account_key_delete.md @@ -0,0 +1,39 @@ +## stackit service-account key delete + +Delete a service account key + +### Synopsis + +Delete a service account key. + +``` +stackit service-account key delete KEY_ID [flags] +``` + +### Examples + +``` + Delete a key with ID "xxx" belonging to the service account with email "my-service-account-1234567@sa.stackit.cloud" + $ stackit service-account key delete xxx --email my-service-account-1234567@sa.stackit.cloud +``` + +### Options + +``` + -e, --email string Service account email + -h, --help Help for "stackit service-account key delete" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit service-account key](./stackit_service-account_key.md) - Provides functionality regarding service account keys + diff --git a/docs/stackit_service-account_key_describe.md b/docs/stackit_service-account_key_describe.md new file mode 100644 index 00000000..2dc3ea91 --- /dev/null +++ b/docs/stackit_service-account_key_describe.md @@ -0,0 +1,39 @@ +## stackit service-account key describe + +Get details of a service account key + +### Synopsis + +Get details of a service account key. Only JSON output is supported. + +``` +stackit service-account key describe KEY_ID [flags] +``` + +### Examples + +``` + Get details of a service account key with ID "xxx" belonging to the service account with email "my-service-account-1234567@sa.stackit.cloud" + $ stackit service-account key describe xxx --email my-service-account-1234567@sa.stackit.cloud +``` + +### Options + +``` + -e, --email string Service account email + -h, --help Help for "stackit service-account key describe" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit service-account key](./stackit_service-account_key.md) - Provides functionality regarding service account keys + diff --git a/docs/stackit_service-account_key_list.md b/docs/stackit_service-account_key_list.md new file mode 100644 index 00000000..508f30f3 --- /dev/null +++ b/docs/stackit_service-account_key_list.md @@ -0,0 +1,46 @@ +## stackit service-account key list + +List all service account keys + +### Synopsis + +List all service account keys. + +``` +stackit service-account key list [flags] +``` + +### Examples + +``` + List all keys belonging to the service account with email "my-service-account-1234567@sa.stackit.cloud" + $ stackit service-account key list --email my-service-account-1234567@sa.stackit.cloud + + List all keys belonging to the service account with email "my-service-account-1234567@sa.stackit.cloud" in JSON format + $ stackit service-account key list --email my-service-account-1234567@sa.stackit.cloud --output-format json + + List up to 10 keys belonging to the service account with email "my-service-account-1234567@sa.stackit.cloud" + $ stackit service-account key list --email my-service-account-1234567@sa.stackit.cloud --limit 10 +``` + +### Options + +``` + -e, --email string Service account email + -h, --help Help for "stackit service-account key list" + --limit int Maximum number of entries to list +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit service-account key](./stackit_service-account_key.md) - Provides functionality regarding service account keys + diff --git a/docs/stackit_service-account_key_update.md b/docs/stackit_service-account_key_update.md new file mode 100644 index 00000000..9340a1f3 --- /dev/null +++ b/docs/stackit_service-account_key_update.md @@ -0,0 +1,49 @@ +## stackit service-account key update + +Update a service account key + +### Synopsis + +Update a service account key. +You can temporarily activate or deactivate the key and/or update its date of expiration. + +``` +stackit service-account key update KEY_ID [flags] +``` + +### Examples + +``` + Temporarily deactivate a key with ID "xxx" of the service account with email "my-service-account-1234567@sa.stackit.cloud" + $ stackit service-account key update xxx --email my-service-account-1234567@sa.stackit.cloud --deactivate + + Activate a key of the service account with email "my-service-account-1234567@sa.stackit.cloud" + $ stackit service-account key update xxx --email my-service-account-1234567@sa.stackit.cloud --activate + + Update the expiration date of a key of the service account with email "my-service-account-1234567@sa.stackit.cloud" + $ stackit service-account key update xxx --email my-service-account-1234567@sa.stackit.cloud --expires-in-days 30 +``` + +### Options + +``` + --activate If set, activates the service account key + --deactivate If set, temporarily deactivates the service account key + -e, --email string Service account email + --expires-in-days int Number of days until expiration + -h, --help Help for "stackit service-account key update" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit service-account key](./stackit_service-account_key.md) - Provides functionality regarding service account keys + diff --git a/docs/stackit_service-account_list.md b/docs/stackit_service-account_list.md new file mode 100644 index 00000000..3f7c55e6 --- /dev/null +++ b/docs/stackit_service-account_list.md @@ -0,0 +1,39 @@ +## stackit service-account list + +List all service accounts + +### Synopsis + +List all service accounts + +``` +stackit service-account list [flags] +``` + +### Examples + +``` + List all service accounts + $ stackit service-account list +``` + +### Options + +``` + -h, --help Help for "stackit service-account list" + --limit int Maximum number of entries to list +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit service-account](./stackit_service-account.md) - Provides functionality for service accounts + diff --git a/docs/stackit_service-account_token.md b/docs/stackit_service-account_token.md new file mode 100644 index 00000000..a6ed6e06 --- /dev/null +++ b/docs/stackit_service-account_token.md @@ -0,0 +1,34 @@ +## stackit service-account token + +Provides functionality regarding service account tokens + +### Synopsis + +Provides functionality regarding service account tokens + +``` +stackit service-account token [flags] +``` + +### Options + +``` + -h, --help Help for "stackit service-account token" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit service-account](./stackit_service-account.md) - Provides functionality for service accounts +* [stackit service-account token create](./stackit_service-account_token_create.md) - Create an access token for a service account +* [stackit service-account token list](./stackit_service-account_token_list.md) - List access tokens of a service account +* [stackit service-account token revoke](./stackit_service-account_token_revoke.md) - Revoke an access token of a service account + diff --git a/docs/stackit_service-account_token_create.md b/docs/stackit_service-account_token_create.md new file mode 100644 index 00000000..139770c4 --- /dev/null +++ b/docs/stackit_service-account_token_create.md @@ -0,0 +1,45 @@ +## stackit service-account token create + +Create an access token for a service account + +### Synopsis + +Create an access token for a service account. +The access token can be then used for API calls (where enabled). +The token is only displayed upon creation, and it will not be recoverable later. + +``` +stackit service-account token create [flags] +``` + +### Examples + +``` + Create an access token for the service account with email "my-service-account-1234567@sa.stackit.cloud" with a default time to live + $ stackit service-account token create --sa-email my-service-account-1234567@sa.stackit.cloud + + Create an access token for the service account with email "my-service-account-1234567@sa.stackit.cloud" with a time to live of 10 days + $ stackit service-account token create --email my-service-account-1234567@sa.stackit.cloud --ttl-days 10 +``` + +### Options + +``` + -e, --email string Service account email + -h, --help Help for "stackit service-account token create" + --ttl-days int How long (in days) the new access token is valid (default 90) +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit service-account token](./stackit_service-account_token.md) - Provides functionality regarding service account tokens + diff --git a/docs/stackit_service-account_token_list.md b/docs/stackit_service-account_token_list.md new file mode 100644 index 00000000..d30ac321 --- /dev/null +++ b/docs/stackit_service-account_token_list.md @@ -0,0 +1,48 @@ +## stackit service-account token list + +List access tokens of a service account + +### Synopsis + +List access tokens of a service account. +Only the metadata about the access tokens is shown, and not the tokens themselves. +Access tokens (including revoked tokens) are returned until they are expired. + +``` +stackit service-account token list [flags] +``` + +### Examples + +``` + List all access tokens of the service account with email "my-service-account-1234567@sa.stackit.cloud" + $ stackit service-account token list --email my-service-account-1234567@sa.stackit.cloud + + List all access tokens of the service account with email "my-service-account-1234567@sa.stackit.cloud" in JSON format + $ stackit service-account token list --email my-service-account-1234567@sa.stackit.cloud --output-format json + + List up to 10 access tokens of the service account with email "my-service-account-1234567@sa.stackit.cloud" + $ stackit service-account token list --email my-service-account-1234567@sa.stackit.cloud --limit 10 +``` + +### Options + +``` + -e, --email string Service account email + -h, --help Help for "stackit service-account token list" + --limit int Maximum number of entries to list +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit service-account token](./stackit_service-account_token.md) - Provides functionality regarding service account tokens + diff --git a/docs/stackit_service-account_token_revoke.md b/docs/stackit_service-account_token_revoke.md new file mode 100644 index 00000000..9dea728a --- /dev/null +++ b/docs/stackit_service-account_token_revoke.md @@ -0,0 +1,41 @@ +## stackit service-account token revoke + +Revoke an access token of a service account + +### Synopsis + +Revoke an access token of a service account. +The access token is instantly revoked, any following calls with the token will be unauthorized. +The token metadata is still stored until the expiration time. + +``` +stackit service-account token revoke TOKEN_ID [flags] +``` + +### Examples + +``` + Revoke an access token with ID "xxx" of the service account with email "my-service-account-1234567@sa.stackit.cloud" + $ stackit service-account token revoke xxx --email my-service-account-1234567@sa.stackit.cloud +``` + +### Options + +``` + -e, --email string Service account email + -h, --help Help for "stackit service-account token revoke" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit service-account token](./stackit_service-account_token.md) - Provides functionality regarding service account tokens + diff --git a/docs/stackit_ske.md b/docs/stackit_ske.md new file mode 100644 index 00000000..5302ea29 --- /dev/null +++ b/docs/stackit_ske.md @@ -0,0 +1,37 @@ +## stackit ske + +Provides functionality for SKE + +### Synopsis + +Provides functionality for STACKIT Kubernetes Engine (SKE) + +``` +stackit ske [flags] +``` + +### Options + +``` + -h, --help Help for "stackit ske" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit](./stackit.md) - Manage STACKIT resources using the command line +* [stackit ske cluster](./stackit_ske_cluster.md) - Provides functionality for SKE cluster +* [stackit ske credentials](./stackit_ske_credentials.md) - Provides functionality for SKE credentials +* [stackit ske describe](./stackit_ske_describe.md) - Get overall details regarding SKE +* [stackit ske disable](./stackit_ske_disable.md) - Disables SKE for a project +* [stackit ske enable](./stackit_ske_enable.md) - Enables SKE for a project +* [stackit ske options](./stackit_ske_options.md) - List SKE provider options + diff --git a/docs/stackit_ske_cluster.md b/docs/stackit_ske_cluster.md new file mode 100644 index 00000000..47ea4edf --- /dev/null +++ b/docs/stackit_ske_cluster.md @@ -0,0 +1,37 @@ +## stackit ske cluster + +Provides functionality for SKE cluster + +### Synopsis + +Provides functionality for STACKIT Kubernetes Engine (SKE) cluster + +``` +stackit ske cluster [flags] +``` + +### Options + +``` + -h, --help Help for "stackit ske cluster" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit ske](./stackit_ske.md) - Provides functionality for SKE +* [stackit ske cluster create](./stackit_ske_cluster_create.md) - Creates an SKE cluster +* [stackit ske cluster delete](./stackit_ske_cluster_delete.md) - Delete a SKE cluster +* [stackit ske cluster describe](./stackit_ske_cluster_describe.md) - Get details of a SKE cluster +* [stackit ske cluster generate-payload](./stackit_ske_cluster_generate-payload.md) - Generates a payload to create/update SKE clusters +* [stackit ske cluster list](./stackit_ske_cluster_list.md) - List all SKE clusters +* [stackit ske cluster update](./stackit_ske_cluster_update.md) - Updates an SKE cluster + diff --git a/docs/stackit_ske_cluster_create.md b/docs/stackit_ske_cluster_create.md new file mode 100644 index 00000000..f1e9db10 --- /dev/null +++ b/docs/stackit_ske_cluster_create.md @@ -0,0 +1,52 @@ +## stackit ske cluster create + +Creates an SKE cluster + +### Synopsis + +Creates a STACKIT Kubernetes Engine (SKE) cluster. +The payload can be provided as a JSON string or a file path prefixed with "@". +See https://docs.api.stackit.cloud/documentation/ske/version/v1#tag/Cluster/operation/SkeService_CreateOrUpdateCluster for information regarding the payload structure. + +``` +stackit ske cluster create CLUSTER_NAME [flags] +``` + +### Examples + +``` + Create an SKE cluster using default configuration + $ stackit ske cluster create my-cluster + + Create an SKE cluster using an API payload sourced from the file "./payload.json" + $ stackit ske cluster create my-cluster --payload @./payload.json + + Create an SKE cluster using an API payload provided as a JSON string + $ stackit ske cluster create my-cluster --payload "{...}" + + Generate a payload with default values, and adapt it with custom values for the different configuration options + $ stackit ske cluster generate-payload > ./payload.json + + $ stackit ske cluster create my-cluster --payload @./payload.json +``` + +### Options + +``` + -h, --help Help for "stackit ske cluster create" + --payload string Request payload (JSON). Can be a string or a file path, if prefixed with "@" (example: @./payload.json). If unset, will use a default payload (you can check it by running "stackit ske cluster generate-payload") +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit ske cluster](./stackit_ske_cluster.md) - Provides functionality for SKE cluster + diff --git a/docs/stackit_ske_cluster_delete.md b/docs/stackit_ske_cluster_delete.md new file mode 100644 index 00000000..059d2c99 --- /dev/null +++ b/docs/stackit_ske_cluster_delete.md @@ -0,0 +1,38 @@ +## stackit ske cluster delete + +Delete a SKE cluster + +### Synopsis + +Delete a STACKIT Kubernetes Engine (SKE) cluster + +``` +stackit ske cluster delete CLUSTER_NAME [flags] +``` + +### Examples + +``` + Delete an SKE cluster with name "my-cluster" + $ stackit ske cluster delete my-cluster +``` + +### Options + +``` + -h, --help Help for "stackit ske cluster delete" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit ske cluster](./stackit_ske_cluster.md) - Provides functionality for SKE cluster + diff --git a/docs/stackit_ske_cluster_describe.md b/docs/stackit_ske_cluster_describe.md new file mode 100644 index 00000000..fc186cfc --- /dev/null +++ b/docs/stackit_ske_cluster_describe.md @@ -0,0 +1,41 @@ +## stackit ske cluster describe + +Get details of a SKE cluster + +### Synopsis + +Get details of a STACKIT Kubernetes Engine (SKE) cluster + +``` +stackit ske cluster describe CLUSTER_NAME [flags] +``` + +### Examples + +``` + Get details of an SKE cluster with name "my-cluster" + $ stackit ske cluster describe my-cluster + + Get details of an SKE cluster with name "my-cluster" in a table format + $ stackit ske cluster describe my-cluster --output-format pretty +``` + +### Options + +``` + -h, --help Help for "stackit ske cluster describe" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit ske cluster](./stackit_ske_cluster.md) - Provides functionality for SKE cluster + diff --git a/docs/stackit_ske_cluster_generate-payload.md b/docs/stackit_ske_cluster_generate-payload.md new file mode 100644 index 00000000..5d18c47d --- /dev/null +++ b/docs/stackit_ske_cluster_generate-payload.md @@ -0,0 +1,47 @@ +## stackit ske cluster generate-payload + +Generates a payload to create/update SKE clusters + +### Synopsis + +Generates a JSON payload with values to be used as --payload input for cluster creation or update. +See https://docs.api.stackit.cloud/documentation/ske/version/v1#tag/Cluster/operation/SkeService_CreateOrUpdateCluster for information regarding the payload structure. + +``` +stackit ske cluster generate-payload [flags] +``` + +### Examples + +``` + Generate a payload with default values, and adapt it with custom values for the different configuration options + $ stackit ske cluster generate-payload > ./payload.json + + $ stackit ske cluster create my-cluster --payload @./payload.json + + Generate a payload with values of a cluster, and adapt it with custom values for the different configuration options + $ stackit ske cluster generate-payload --cluster-name my-cluster > ./payload.json + + $ stackit ske cluster update my-cluster --payload @./payload.json +``` + +### Options + +``` + -n, --cluster-name string If set, generates the payload with the current state of the given cluster. If unset, generates the payload with default values + -h, --help Help for "stackit ske cluster generate-payload" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit ske cluster](./stackit_ske_cluster.md) - Provides functionality for SKE cluster + diff --git a/docs/stackit_ske_cluster_list.md b/docs/stackit_ske_cluster_list.md new file mode 100644 index 00000000..259e29fe --- /dev/null +++ b/docs/stackit_ske_cluster_list.md @@ -0,0 +1,45 @@ +## stackit ske cluster list + +List all SKE clusters + +### Synopsis + +List all STACKIT Kubernetes Engine (SKE) clusters + +``` +stackit ske cluster list [flags] +``` + +### Examples + +``` + List all SKE clusters + $ stackit ske cluster list + + List all SKE clusters in JSON format + $ stackit ske cluster list --output-format json + + List up to 10 SKE clusters + $ stackit ske cluster list --limit 10 +``` + +### Options + +``` + -h, --help Help for "stackit ske cluster list" + --limit int Maximum number of entries to list +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit ske cluster](./stackit_ske_cluster.md) - Provides functionality for SKE cluster + diff --git a/docs/stackit_ske_cluster_update.md b/docs/stackit_ske_cluster_update.md new file mode 100644 index 00000000..de5c9391 --- /dev/null +++ b/docs/stackit_ske_cluster_update.md @@ -0,0 +1,49 @@ +## stackit ske cluster update + +Updates an SKE cluster + +### Synopsis + +Updates a STACKIT Kubernetes Engine (SKE) cluster. +The payload can be provided as a JSON string or a file path prefixed with "@". +See https://docs.api.stackit.cloud/documentation/ske/version/v1#tag/Cluster/operation/SkeService_CreateOrUpdateCluster for information regarding the payload structure. + +``` +stackit ske cluster update CLUSTER_NAME [flags] +``` + +### Examples + +``` + Update an SKE cluster using an API payload sourced from the file "./payload.json" + $ stackit ske cluster update my-cluster --payload @./payload.json + + Update an SKE cluster using an API payload provided as a JSON string + $ stackit ske cluster update my-cluster --payload "{...}" + + Generate a payload with the current values of a cluster, and adapt it with custom values for the different configuration options + $ stackit ske cluster generate-payload --cluster-name my-cluster > ./payload.json + + $ stackit ske cluster update my-cluster --payload @./payload.json +``` + +### Options + +``` + -h, --help Help for "stackit ske cluster update" + --payload string Request payload (JSON). Can be a string or a file path, if prefixed with "@". Example: @./payload.json +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit ske cluster](./stackit_ske_cluster.md) - Provides functionality for SKE cluster + diff --git a/docs/stackit_ske_credentials.md b/docs/stackit_ske_credentials.md new file mode 100644 index 00000000..b5779700 --- /dev/null +++ b/docs/stackit_ske_credentials.md @@ -0,0 +1,33 @@ +## stackit ske credentials + +Provides functionality for SKE credentials + +### Synopsis + +Provides functionality for STACKIT Kubernetes Engine (SKE) credentials + +``` +stackit ske credentials [flags] +``` + +### Options + +``` + -h, --help Help for "stackit ske credentials" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit ske](./stackit_ske.md) - Provides functionality for SKE +* [stackit ske credentials describe](./stackit_ske_credentials_describe.md) - Get details of the credentials associated to a SKE cluster +* [stackit ske credentials rotate](./stackit_ske_credentials_rotate.md) - Rotate credentials associated to a SKE cluster + diff --git a/docs/stackit_ske_credentials_describe.md b/docs/stackit_ske_credentials_describe.md new file mode 100644 index 00000000..8f36167d --- /dev/null +++ b/docs/stackit_ske_credentials_describe.md @@ -0,0 +1,41 @@ +## stackit ske credentials describe + +Get details of the credentials associated to a SKE cluster + +### Synopsis + +Get details of the credentials associated to a STACKIT Kubernetes Engine (SKE) cluster + +``` +stackit ske credentials describe CLUSTER_NAME [flags] +``` + +### Examples + +``` + Get details of the credentials associated to the SKE cluster with name "my-cluster" + $ stackit ske credentials describe my-cluster + + Get details of the credentials associated to the SKE cluster with name "my-cluster" in a table format + $ stackit ske credentials describe my-cluster --output-format pretty +``` + +### Options + +``` + -h, --help Help for "stackit ske credentials describe" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit ske credentials](./stackit_ske_credentials.md) - Provides functionality for SKE credentials + diff --git a/docs/stackit_ske_credentials_rotate.md b/docs/stackit_ske_credentials_rotate.md new file mode 100644 index 00000000..2ace064c --- /dev/null +++ b/docs/stackit_ske_credentials_rotate.md @@ -0,0 +1,38 @@ +## stackit ske credentials rotate + +Rotate credentials associated to a SKE cluster + +### Synopsis + +Rotate credentials associated to a STACKIT Kubernetes Engine (SKE) cluster. The old credentials will be invalid after the operation + +``` +stackit ske credentials rotate CLUSTER_NAME [flags] +``` + +### Examples + +``` + Rotate credentials associated to the SKE cluster with name "my-cluster" + $ stackit ske credentials rotate my-cluster +``` + +### Options + +``` + -h, --help Help for "stackit ske credentials rotate" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit ske credentials](./stackit_ske_credentials.md) - Provides functionality for SKE credentials + diff --git a/docs/stackit_ske_describe.md b/docs/stackit_ske_describe.md new file mode 100644 index 00000000..d8a40a15 --- /dev/null +++ b/docs/stackit_ske_describe.md @@ -0,0 +1,38 @@ +## stackit ske describe + +Get overall details regarding SKE + +### Synopsis + +Get overall details regarding STACKIT Kubernetes Engine (SKE) + +``` +stackit ske describe [flags] +``` + +### Examples + +``` + Get details regarding SKE functionality on your project + $ stackit ske describe +``` + +### Options + +``` + -h, --help Help for "stackit ske describe" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit ske](./stackit_ske.md) - Provides functionality for SKE + diff --git a/docs/stackit_ske_disable.md b/docs/stackit_ske_disable.md new file mode 100644 index 00000000..59e8b975 --- /dev/null +++ b/docs/stackit_ske_disable.md @@ -0,0 +1,38 @@ +## stackit ske disable + +Disables SKE for a project + +### Synopsis + +Disables STACKIT Kubernetes Engine (SKE) for a project. It will delete all associated clusters + +``` +stackit ske disable [flags] +``` + +### Examples + +``` + Disable SKE functionality for your project, deleting all associated clusters + $ stackit ske disable +``` + +### Options + +``` + -h, --help Help for "stackit ske disable" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit ske](./stackit_ske.md) - Provides functionality for SKE + diff --git a/docs/stackit_ske_enable.md b/docs/stackit_ske_enable.md new file mode 100644 index 00000000..5df5c002 --- /dev/null +++ b/docs/stackit_ske_enable.md @@ -0,0 +1,38 @@ +## stackit ske enable + +Enables SKE for a project + +### Synopsis + +Enables STACKIT Kubernetes Engine (SKE) for a project + +``` +stackit ske enable [flags] +``` + +### Examples + +``` + Enable SKE functionality for your project + $ stackit ske enable +``` + +### Options + +``` + -h, --help Help for "stackit ske enable" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit ske](./stackit_ske.md) - Provides functionality for SKE + diff --git a/docs/stackit_ske_options.md b/docs/stackit_ske_options.md new file mode 100644 index 00000000..8bdd5533 --- /dev/null +++ b/docs/stackit_ske_options.md @@ -0,0 +1,50 @@ +## stackit ske options + +List SKE provider options + +### Synopsis + +List STACKIT Kubernetes Engine (SKE) provider options (availability zones, Kubernetes versions, machine images and types, volume types) +Pass one or more flags to filter what categories are shown + +``` +stackit ske options [flags] +``` + +### Examples + +``` + List SKE options for all categories + $ stackit ske options + + List SKE options regarding Kubernetes versions only + $ stackit ske options --kubernetes-versions + + List SKE options regarding Kubernetes versions and machine images + $ stackit ske options --kubernetes-versions --machine-images +``` + +### Options + +``` + --availability-zones Lists availability zones + -h, --help Help for "stackit ske options" + --kubernetes-versions Lists supported kubernetes versions + --machine-images Lists supported machine images + --machine-types Lists supported machine types + --volume-types Lists supported volume types +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit ske](./stackit_ske.md) - Provides functionality for SKE + diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..84df72b5 --- /dev/null +++ b/go.mod @@ -0,0 +1,59 @@ +module stackit + +go 1.21 + +require ( + github.com/golang-jwt/jwt/v5 v5.2.0 + github.com/google/go-cmp v0.6.0 + github.com/google/uuid v1.6.0 + github.com/jedib0t/go-pretty/v6 v6.5.3 + github.com/spf13/cobra v1.8.0 + github.com/spf13/pflag v1.0.5 + github.com/spf13/viper v1.18.2 + github.com/stackitcloud/stackit-sdk-go/core v0.7.6 + github.com/stackitcloud/stackit-sdk-go/services/dns v0.8.2 + github.com/stackitcloud/stackit-sdk-go/services/membership v0.3.4 + github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v0.10.3 + github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.7.5 + github.com/stackitcloud/stackit-sdk-go/services/serviceaccount v0.3.4 + github.com/stackitcloud/stackit-sdk-go/services/ske v0.9.2 + github.com/zalando/go-keyring v0.2.3 + golang.org/x/mod v0.14.0 + golang.org/x/oauth2 v0.16.0 +) + +require ( + github.com/MicahParks/keyfunc/v2 v2.1.0 // indirect + github.com/alessio/shellescape v1.4.2 // indirect + github.com/cenkalti/backoff/v4 v4.2.1 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.3 // indirect + github.com/danieljoos/wincred v1.2.1 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect + github.com/pelletier/go-toml/v2 v2.1.1 // indirect + github.com/rivo/uniseg v0.4.4 // indirect + github.com/rogpeppe/go-internal v1.10.0 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/exp v0.0.0-20240119083558-1b970713d09a // indirect + golang.org/x/sys v0.16.0 // indirect + golang.org/x/text v0.14.0 // indirect + google.golang.org/appengine v1.6.8 // indirect + google.golang.org/protobuf v1.32.0 // indirect + gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..a93973d5 --- /dev/null +++ b/go.sum @@ -0,0 +1,156 @@ +github.com/MicahParks/keyfunc/v2 v2.1.0 h1:6ZXKb9Rp6qp1bDbJefnG7cTH8yMN1IC/4nf+GVjO99k= +github.com/MicahParks/keyfunc/v2 v2.1.0/go.mod h1:rW42fi+xgLJ2FRRXAfNx9ZA8WpD4OeE/yHVMteCkw9k= +github.com/alessio/shellescape v1.4.2 h1:MHPfaU+ddJ0/bYWpgIeUnQUqKrlJ1S7BfEYPM4uEoM0= +github.com/alessio/shellescape v1.4.2/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= +github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= +github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cpuguy83/go-md2man/v2 v2.0.3 h1:qMCsGGgs+MAzDFyp9LpAe1Lqy/fY/qCovCm0qnXZOBM= +github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/danieljoos/wincred v1.2.1 h1:dl9cBrupW8+r5250DYkYxocLeZ1Y4vB1kxgtjxw8GQs= +github.com/danieljoos/wincred v1.2.1/go.mod h1:uGaFL9fDn3OLTvzCGulzE+SzjEe5NGlh5FdCcyfPwps= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= +github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jedib0t/go-pretty/v6 v6.5.3 h1:GIXn6Er/anHTkVUoufs7ptEvxdD6KIhR7Axa2wYCPF0= +github.com/jedib0t/go-pretty/v6 v6.5.3/go.mod h1:5LQIxa52oJ/DlDSLv0HEkWOFMDGoWkJb9ss5KqPpJBg= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI= +github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= +github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= +github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= +github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= +github.com/stackitcloud/stackit-sdk-go/core v0.7.6 h1:AhbhfshlDJq0wuJRlw+TbwiX0slyA72Aei544g5CnHM= +github.com/stackitcloud/stackit-sdk-go/core v0.7.6/go.mod h1:ePb/1v9P1++W/92rN9mdToUkaMiK7lz4SVFY2KtSrB4= +github.com/stackitcloud/stackit-sdk-go/services/dns v0.8.2 h1:Wj3A+BAitSK74dRMxEGoKU1itEZmjwrAECT/CgsEJOQ= +github.com/stackitcloud/stackit-sdk-go/services/dns v0.8.2/go.mod h1:RYRnST/3Kz5GmxMmFvsaYFblfZ/LMxw8r9DNfnRhX/4= +github.com/stackitcloud/stackit-sdk-go/services/membership v0.3.4 h1:0OT/UBP55/GPMm9Tks9Uhb+PvP/2zI6ZUySfh7px+kY= +github.com/stackitcloud/stackit-sdk-go/services/membership v0.3.4/go.mod h1:6ovfcQJ96ivkBpSI933lVl2a/SWprpVGoK6YNKycLps= +github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v0.10.3 h1:M7ALIg1tE8MFLLw9Um0iyvdBgIhl83tJ0sWRjP7YqMM= +github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v0.10.3/go.mod h1:LWfUBjGQWF3SZivQdUdAC/WxJkx8ImJKy5GFMV3tXHY= +github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.7.5 h1:Gu0z8MpErzBHxb9xx8B/4DduxckDmBRPWNaeoVcE8cQ= +github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.7.5/go.mod h1:MQ5eGWFmnDf9wUArqZ2g+nwJgMDkYDQUkoRVutaHrms= +github.com/stackitcloud/stackit-sdk-go/services/serviceaccount v0.3.4 h1:XNL7bk5mwCovV8a3oIIC9PlNpPTUG3XNwdRqHS5V2no= +github.com/stackitcloud/stackit-sdk-go/services/serviceaccount v0.3.4/go.mod h1:0b04m24igiVzH3+vnjgwO1iFwUoLJNOta2dNIlRUrMI= +github.com/stackitcloud/stackit-sdk-go/services/ske v0.9.2 h1:dbSG/2AzrFH9QjJ5VcUA1wKspEWnYtt+kJzPc/anWLA= +github.com/stackitcloud/stackit-sdk-go/services/ske v0.9.2/go.mod h1:yol/cpTN4b1+WMnDsHQz+ItzgiHbj1LpLyrjIWiQODs= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zalando/go-keyring v0.2.3 h1:v9CUu9phlABObO4LPWycf+zwMG7nlbb3t/B5wa97yms= +github.com/zalando/go-keyring v0.2.3/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/exp v0.0.0-20240119083558-1b970713d09a h1:Q8/wZp0KX97QFTc2ywcOE0YRjZPVIx+MXInMzdvQqcA= +golang.org/x/exp v0.0.0-20240119083558-1b970713d09a/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= +golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/oauth2 v0.16.0 h1:aDkGMBSYxElaoP81NpoUoz2oo2R2wHdZpGToUxfyQrQ= +golang.org/x/oauth2 v0.16.0/go.mod h1:hqZ+0LWXsiVoZpeld6jVt06P3adbS2Uu911W1SsJv2o= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= +google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= +google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/golang-ci.yaml b/golang-ci.yaml new file mode 100644 index 00000000..7eceed18 --- /dev/null +++ b/golang-ci.yaml @@ -0,0 +1,100 @@ +# This file contains all available configuration options +# with their default values. + +# options for analysis running +run: + # default concurrency is a available CPU number + concurrency: 4 + + # timeout for analysis, e.g. 30s, 5m, default is 1m + timeout: 5m +linters-settings: + goimports: + # put imports beginning with prefix after 3rd-party packages; + # it's a comma-separated list of prefixes + local-prefixes: github.com/freiheit-com/nmww + depguard: + list-type: blacklist + include-go-root: false + packages: + - github.com/stretchr/testify + packages-with-error-message: + # specify an error message to output when a blacklisted package is used + - github.com/stretchr/testify: "do not use a testing framework" + misspell: + # Correct spellings using locale preferences for US or UK. + # Default is to use a neutral variety of English. + # Setting locale to US will correct the British spelling of 'colour' to 'color'. + locale: US + golint: + min-confidence: 0.8 + gosec: + excludes: + # Suppressions: (see https://github.com/securego/gosec#available-rules for details) + - G104 # "Audit errors not checked" -> which we don't need and is a badly implemented version of errcheck + - G102 # "Bind to all interfaces" -> since this is normal in k8s + - G304 # "File path provided as taint input" -> too many false positives + - G307 # "Deferring unsafe method "Close" on type "io.ReadCloser" -> false positive when calling defer resp.Body.Close() + nakedret: + max-func-lines: 0 + revive: + ignore-generated-header: true + severity: error + # https://github.com/mgechev/revive + rules: + - name: errorf + - name: context-as-argument + - name: error-return + - name: increment-decrement + - name: indent-error-flow + - name: superfluous-else + - name: unused-parameter + - name: unreachable-code + - name: atomic + - name: empty-lines + - name: early-return + gocritic: + enabled-tags: + - performance + - style + - experimental + disabled-checks: + - wrapperFunc + - typeDefFirst + - ifElseChain + - dupImport # https://github.com/go-critic/go-critic/issues/845 +linters: + enable: + # https://golangci-lint.run/usage/linters/ + # default linters + - gosimple + - govet + - ineffassign + - staticcheck + - typecheck + - unused + # additional linters + - errorlint + - exportloopref + - gochecknoinits + - gocritic + - gofmt + - goimports + - gosec + - misspell + - nakedret + - revive + - depguard + - bodyclose + - sqlclosecheck + - wastedassign + - forcetypeassert + - errcheck + disable: + - structcheck # deprecated + - deadcode # deprecated + - varcheck # deprecated + - noctx # false positive: finds errors with http.NewRequest that dont make sense + - unparam # false positives +issues: + exclude-use-default: false diff --git a/internal/cmd/auth/activate-service-account/activate_service_account.go b/internal/cmd/auth/activate-service-account/activate_service_account.go new file mode 100644 index 00000000..b50ff9b2 --- /dev/null +++ b/internal/cmd/auth/activate-service-account/activate_service_account.go @@ -0,0 +1,124 @@ +package activateserviceaccount + +import ( + "errors" + "fmt" + "stackit/internal/pkg/args" + "stackit/internal/pkg/auth" + cliErr "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/flags" + + "github.com/spf13/cobra" + sdkAuth "github.com/stackitcloud/stackit-sdk-go/core/auth" + sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" +) + +const ( + serviceAccountTokenFlag = "service-account-token" + serviceAccountKeyPathFlag = "service-account-key-path" + privateKeyPathFlag = "private-key-path" + tokenCustomEndpointFlag = "token-custom-endpoint" + jwksCustomEndpointFlag = "jwks-custom-endpoint" +) + +type inputModel struct { + ServiceAccountToken string + ServiceAccountKeyPath string + PrivateKeyPath string + TokenCustomEndpoint string + JwksCustomEndpoint string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "activate-service-account", + Short: "Activate service account authentication", + Long: fmt.Sprintf("%s\n%s\n%s", + "Activate authentication using service account credentials.", + "Subsequent commands will be authenticated using the service account credentials provided.", + "For more details on how to configure your service account, check our Authentication guide at https://dev.azure.com/schwarzit/schwarzit.stackit-public/_git/stackit-cli-beta?path=/AUTHENTICATION.md.", + ), + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Activate service account authentication in the STACKIT CLI using a service account key which includes the private key`, + "$ stackit auth activate-service-account --service-account-key-path path/to/service_account_key.json"), + examples.NewExample( + `Activate service account authentication in the STACKIT CLI using the service account key and explicitly providing the private key in a PEM encoded file, which will take precedence over the one in the service account key`, + "$ stackit auth activate-service-account --service-account-key-path path/to/service_account_key.json --private-key-path path/to/private.key"), + examples.NewExample( + `Activate service account authentication in the STACKIT CLI using the service account token`, + "$ stackit auth activate-service-account --service-account-token my-service-account-token"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + model := parseInput(cmd) + + err := storeFlags(model) + if err != nil { + return err + } + + cfg := &sdkConfig.Configuration{ + Token: model.ServiceAccountToken, + ServiceAccountKeyPath: model.ServiceAccountKeyPath, + PrivateKeyPath: model.PrivateKeyPath, + TokenCustomUrl: model.TokenCustomEndpoint, + JWKSCustomUrl: model.JwksCustomEndpoint, + } + + // Setup authentication based on the provided credentials and the environment + // Initializes the authentication flow + rt, err := sdkAuth.SetupAuth(cfg) + if err != nil { + return &cliErr.ActivateServiceAccountError{} + } + + // Authenticates the service account and stores credentials + email, err := auth.AuthenticateServiceAccount(rt) + if err != nil { + var activateServiceAccountError *cliErr.ActivateServiceAccountError + if !errors.As(err, &activateServiceAccountError) { + return fmt.Errorf("authenticate service account: %w", err) + } + return err + } + + cmd.Printf("You have been successfully authenticated to the STACKIT CLI!\nService account email: %s\n", email) + + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(serviceAccountTokenFlag, "", "Service account long-lived access token") + cmd.Flags().String(serviceAccountKeyPathFlag, "", "Service account key path") + cmd.Flags().String(privateKeyPathFlag, "", "RSA private key path. It takes precedence over the private key included in the service account key, if present") + cmd.Flags().String(tokenCustomEndpointFlag, "", "Custom endpoint for the token API, which is used to request access tokens when the service-account authentication is activated") + cmd.Flags().String(jwksCustomEndpointFlag, "", "Custom endpoint for the jwks API, which is used to get the json web key sets (jwks) to validate tokens when the service-account authentication is activated") +} + +func parseInput(cmd *cobra.Command) *inputModel { + return &inputModel{ + ServiceAccountToken: flags.FlagToStringValue(cmd, serviceAccountTokenFlag), + ServiceAccountKeyPath: flags.FlagToStringValue(cmd, serviceAccountKeyPathFlag), + PrivateKeyPath: flags.FlagToStringValue(cmd, privateKeyPathFlag), + TokenCustomEndpoint: flags.FlagToStringValue(cmd, tokenCustomEndpointFlag), + JwksCustomEndpoint: flags.FlagToStringValue(cmd, jwksCustomEndpointFlag), + } +} + +func storeFlags(model *inputModel) error { + err := auth.SetAuthField(auth.TOKEN_CUSTOM_ENDPOINT, model.TokenCustomEndpoint) + if err != nil { + return fmt.Errorf("set %s: %w", auth.TOKEN_CUSTOM_ENDPOINT, err) + } + err = auth.SetAuthField(auth.JWKS_CUSTOM_ENDPOINT, model.JwksCustomEndpoint) + if err != nil { + return fmt.Errorf("set %s: %w", auth.JWKS_CUSTOM_ENDPOINT, err) + } + return nil +} diff --git a/internal/cmd/auth/activate-service-account/activate_service_account_test.go b/internal/cmd/auth/activate-service-account/activate_service_account_test.go new file mode 100644 index 00000000..7ddb39a3 --- /dev/null +++ b/internal/cmd/auth/activate-service-account/activate_service_account_test.go @@ -0,0 +1,119 @@ +package activateserviceaccount + +import ( + "stackit/internal/pkg/globalflags" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + serviceAccountTokenFlag: "token", + serviceAccountKeyPathFlag: "sa_key", + privateKeyPathFlag: "private_key", + tokenCustomEndpointFlag: "token_url", + jwksCustomEndpointFlag: "jwks_url", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + ServiceAccountToken: "token", + ServiceAccountKeyPath: "sa_key", + PrivateKeyPath: "private_key", + TokenCustomEndpoint: "token_url", + JwksCustomEndpoint: "jwks_url", + } + for _, mod := range mods { + mod(model) + } + return model +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: true, + expectedModel: &inputModel{ + ServiceAccountToken: "", + ServiceAccountKeyPath: "", + PrivateKeyPath: "", + TokenCustomEndpoint: "", + JwksCustomEndpoint: "", + }, + }, + { + description: "zero values", + flagValues: map[string]string{ + serviceAccountTokenFlag: "", + serviceAccountKeyPathFlag: "", + privateKeyPathFlag: "", + tokenCustomEndpointFlag: "", + jwksCustomEndpointFlag: "", + }, + isValid: true, + expectedModel: &inputModel{ + ServiceAccountToken: "", + ServiceAccountKeyPath: "", + PrivateKeyPath: "", + TokenCustomEndpoint: "", + JwksCustomEndpoint: "", + }, + }, + { + description: "invalid_flag", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues["test_flag"] = "test" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + model := parseInput(cmd) + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/auth/auth.go b/internal/cmd/auth/auth.go new file mode 100644 index 00000000..da70f38e --- /dev/null +++ b/internal/cmd/auth/auth.go @@ -0,0 +1,27 @@ +package auth + +import ( + activateserviceaccount "stackit/internal/cmd/auth/activate-service-account" + "stackit/internal/cmd/auth/login" + "stackit/internal/pkg/args" + "stackit/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "auth", + Short: "Provides authentication functionality", + Long: "Provides authentication functionality", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd) + return cmd +} + +func addSubcommands(cmd *cobra.Command) { + cmd.AddCommand(login.NewCmd()) + cmd.AddCommand(activateserviceaccount.NewCmd()) +} diff --git a/internal/cmd/auth/login/login.go b/internal/cmd/auth/login/login.go new file mode 100644 index 00000000..200ef2f8 --- /dev/null +++ b/internal/cmd/auth/login/login.go @@ -0,0 +1,35 @@ +package login + +import ( + "fmt" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/auth" + "stackit/internal/pkg/examples" + + "github.com/spf13/cobra" +) + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "login", + Short: "Login to the STACKIT CLI", + Long: "Login to the STACKIT CLI", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Login to the STACKIT CLI. This command will open a browser window where you can login to your STACKIT account`, + "$ stackit auth login"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + err := auth.AuthorizeUser() + if err != nil { + return fmt.Errorf("authorization failed: %w", err) + } + + cmd.Println("Successfully logged into STACKIT CLI.") + return nil + }, + } + return cmd +} diff --git a/internal/cmd/config/config.go b/internal/cmd/config/config.go new file mode 100644 index 00000000..3a6d43c1 --- /dev/null +++ b/internal/cmd/config/config.go @@ -0,0 +1,29 @@ +package config + +import ( + "stackit/internal/cmd/config/list" + "stackit/internal/cmd/config/set" + "stackit/internal/cmd/config/unset" + "stackit/internal/pkg/args" + "stackit/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "config", + Short: "CLI configuration options", + Long: "CLI configuration options", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd) + return cmd +} + +func addSubcommands(cmd *cobra.Command) { + cmd.AddCommand(list.NewCmd()) + cmd.AddCommand(set.NewCmd()) + cmd.AddCommand(unset.NewCmd()) +} diff --git a/internal/cmd/config/list/list.go b/internal/cmd/config/list/list.go new file mode 100644 index 00000000..83e31866 --- /dev/null +++ b/internal/cmd/config/list/list.go @@ -0,0 +1,72 @@ +package list + +import ( + "fmt" + "slices" + "sort" + "stackit/internal/pkg/args" + "stackit/internal/pkg/config" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/tables" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List the current CLI configuration values", + Long: "List the current CLI configuration values", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List your active configuration`, + "$ stackit config list"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + err := viper.ReadInConfig() + if err != nil { + return fmt.Errorf("read config file: %w", err) + } + + configData := viper.AllSettings() + + // Sort the config options by key + configKeys := make([]string, 0, len(configData)) + for k := range configData { + configKeys = append(configKeys, k) + } + sort.Strings(configKeys) + + table := tables.NewTable() + table.SetHeader("NAME", "VALUE") + for _, key := range configKeys { + value := configData[key] + valueString, ok := value.(string) + if !ok || valueString == "" { + continue + } + + // Don't show unsupported (deprecated or user-inputted) configuration options + // that might be present in the config file + if !slices.Contains(config.ConfigKeys, key) { + continue + } + + // Replace "_" with "-" to match the flags + key = strings.ReplaceAll(key, "_", "-") + + table.AddRow(key, valueString) + table.AddSeparator() + } + err = table.Display(cmd) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + return nil + }, + } + return cmd +} diff --git a/internal/cmd/config/set/set.go b/internal/cmd/config/set/set.go new file mode 100644 index 00000000..5c463443 --- /dev/null +++ b/internal/cmd/config/set/set.go @@ -0,0 +1,151 @@ +package set + +import ( + "fmt" + "time" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/config" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/flags" + "stackit/internal/pkg/globalflags" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +const ( + sessionTimeLimitFlag = "session-time-limit" + dnsCustomEndpointFlag = "dns-custom-endpoint" + membershipCustomEndpointFlag = "membership-custom-endpoint" + mongoDBFlexCustomEndpointFlag = "mongodbflex-custom-endpoint" + serviceAccountCustomEndpointFlag = "service-account-custom-endpoint" + skeCustomEndpointFlag = "ske-custom-endpoint" + resourceManagerCustomEndpointFlag = "resource-manager-custom-endpoint" +) + +type inputModel struct { + SessionTimeLimit *string + // If true, projectId has been set + ProjectIdSet bool +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "set", + Short: "Set CLI configuration options", + Long: `Set CLI configuration options. +All of the configuration options can be set using an environment variable, which takes precedence over what is configured. +The environment variable is the name of the flag, with underscores ("_") instead of dashes ("-") and the "STACKIT" prefix. +Example: to set the project ID you can set the environment variable STACKIT_PROJECT_ID`, + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Set a project ID in your active configuration. This project ID will be used by every command, as long as it's not overridden by the "STACKIT_PROJECT_ID" environment variable or the command flag`, + "$ stackit config set --project-id xxx"), + examples.NewExample( + `Set the session time limit to 1 hour. After this time you will be prompted to login again to be able to execute commands that need authentication`, + "$ stackit config set --session-time-limit 1h"), + examples.NewExample( + `Set the DNS custom endpoint. This endpoint will be used on all calls to the DNS API, unless overridden by the "STACKIT_DNS_CUSTOM_ENDPOINT" environment variable`, + "$ stackit config set --dns-custom-endpoint https://dns.stackit.cloud"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + model, err := parseInput(cmd) + if err != nil { + return err + } + + if model.SessionTimeLimit != nil { + cmd.Println("Authenticate again to apply changes to session time limit") + viper.Set(config.SessionTimeLimitKey, *model.SessionTimeLimit) + } + + // If project ID was set, remove the value for project name stored in config + if model.ProjectIdSet { + viper.Set(config.ProjectNameKey, "") + } + + err = viper.WriteConfig() + if err != nil { + return fmt.Errorf("write new config to file: %w", err) + } + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(sessionTimeLimitFlag, "", "Maximum time before authentication is required again. Can't be larger than 24h. Examples: 3h, 5h30m40s (BETA: currently values greater than 2h have no effect)") + cmd.Flags().String(dnsCustomEndpointFlag, "", "DNS custom endpoint") + cmd.Flags().String(membershipCustomEndpointFlag, "", "Membership custom endpoint") + cmd.Flags().String(mongoDBFlexCustomEndpointFlag, "", "MongoDB Flex custom endpoint") + cmd.Flags().String(serviceAccountCustomEndpointFlag, "", "Service Account custom endpoint") + cmd.Flags().String(skeCustomEndpointFlag, "", "SKE custom endpoint") + cmd.Flags().String(resourceManagerCustomEndpointFlag, "", "Resource manager custom endpoint") + + err := viper.BindPFlag(config.DNSCustomEndpointKey, cmd.Flags().Lookup(dnsCustomEndpointFlag)) + cobra.CheckErr(err) + err = viper.BindPFlag(config.MembershipCustomEndpointKey, cmd.Flags().Lookup(membershipCustomEndpointFlag)) + cobra.CheckErr(err) + err = viper.BindPFlag(config.MongoDBFlexCustomEndpointKey, cmd.Flags().Lookup(mongoDBFlexCustomEndpointFlag)) + cobra.CheckErr(err) + err = viper.BindPFlag(config.ServiceAccountCustomEndpointKey, cmd.Flags().Lookup(serviceAccountCustomEndpointFlag)) + cobra.CheckErr(err) + err = viper.BindPFlag(config.SKECustomEndpointKey, cmd.Flags().Lookup(skeCustomEndpointFlag)) + cobra.CheckErr(err) + err = viper.BindPFlag(config.ResourceManagerEndpointKey, cmd.Flags().Lookup(skeCustomEndpointFlag)) + cobra.CheckErr(err) +} + +func parseInput(cmd *cobra.Command) (*inputModel, error) { + sessionTimeLimit, err := parseSessionTimeLimit(cmd) + if err != nil { + return nil, &errors.FlagValidationError{ + Flag: sessionTimeLimitFlag, + Details: err.Error(), + } + } + + // values.FlagToStringPointer pulls the projectId from passed flags + // globalflags.Parse uses the flags, and fallsback to config file + // To check if projectId was passed, we use the first rather than the second + projectIdFromFlag := flags.FlagToStringPointer(cmd, globalflags.ProjectIdFlag) + projectIdSet := false + if projectIdFromFlag != nil { + projectIdSet = true + } + + return &inputModel{ + SessionTimeLimit: sessionTimeLimit, + ProjectIdSet: projectIdSet, + }, nil +} + +func parseSessionTimeLimit(cmd *cobra.Command) (*string, error) { + sessionTimeLimit := flags.FlagToStringPointer(cmd, sessionTimeLimitFlag) + if sessionTimeLimit == nil { + return nil, nil + } + + // time.ParseDuration doesn't recognize unit "d", for simplicity we allow the value "1d" + if *sessionTimeLimit == "1d" { + *sessionTimeLimit = "24h" + } + + duration, err := time.ParseDuration(*sessionTimeLimit) + if err != nil { + return nil, fmt.Errorf("parse value \"%s\": %w", *sessionTimeLimit, err) + } + if duration <= 0 { + return nil, fmt.Errorf("value must be positive") + } + if duration > time.Duration(24)*time.Hour { + return nil, fmt.Errorf("value can't be larger than 24h") + } + + return sessionTimeLimit, nil +} diff --git a/internal/cmd/config/set/set_test.go b/internal/cmd/config/set/set_test.go new file mode 100644 index 00000000..f0686939 --- /dev/null +++ b/internal/cmd/config/set/set_test.go @@ -0,0 +1,163 @@ +package set + +import ( + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/utils" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/uuid" +) + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "valid session time limit 1", + flagValues: map[string]string{ + sessionTimeLimitFlag: "1h", + }, + isValid: true, + expectedModel: &inputModel{ + SessionTimeLimit: utils.Ptr("1h"), + }, + }, + { + description: "valid session time limit 2", + flagValues: map[string]string{ + sessionTimeLimitFlag: "5h30m40s", + }, + isValid: true, + expectedModel: &inputModel{ + SessionTimeLimit: utils.Ptr("5h30m40s"), + }, + }, + { + description: "valid session time limit 3", + flagValues: map[string]string{ + sessionTimeLimitFlag: "1h2m3s4ms5us6ns", + }, + isValid: true, + expectedModel: &inputModel{ + SessionTimeLimit: utils.Ptr("1h2m3s4ms5us6ns"), + }, + }, + { + description: "valid session time limit 4", + flagValues: map[string]string{ + sessionTimeLimitFlag: "1d", + }, + isValid: true, + expectedModel: &inputModel{ + SessionTimeLimit: utils.Ptr("24h"), + }, + }, + { + description: "invalid session time limit 1", + flagValues: map[string]string{ + sessionTimeLimitFlag: "foo", + }, + isValid: false, + }, + { + description: "invalid session time limit 2", + flagValues: map[string]string{ + sessionTimeLimitFlag: "", + }, + isValid: false, + }, + { + description: "invalid session time limit 3", + flagValues: map[string]string{ + sessionTimeLimitFlag: "1", + }, + isValid: false, + }, + { + description: "invalid session time limit 4", + flagValues: map[string]string{ + sessionTimeLimitFlag: "h", + }, + isValid: false, + }, + { + description: "invalid session time limit 5", + flagValues: map[string]string{ + sessionTimeLimitFlag: "0h", + }, + isValid: false, + }, + { + description: "invalid session time limit 6", + flagValues: map[string]string{ + sessionTimeLimitFlag: "-1h", + }, + isValid: false, + }, + { + description: "invalid session time limit 7", + flagValues: map[string]string{ + sessionTimeLimitFlag: "25h", + }, + isValid: false, + }, + { + description: "project ID set", + flagValues: map[string]string{ + globalflags.ProjectIdFlag: uuid.NewString(), + }, + isValid: true, + expectedModel: &inputModel{ + ProjectIdSet: true, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/config/unset/unset.go b/internal/cmd/config/unset/unset.go new file mode 100644 index 00000000..a635b63d --- /dev/null +++ b/internal/cmd/config/unset/unset.go @@ -0,0 +1,128 @@ +package unset + +import ( + "fmt" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/config" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/flags" + "stackit/internal/pkg/globalflags" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +const ( + asyncFlag = globalflags.AsyncFlag + outputFormatFlag = globalflags.OutputFormatFlag + projectIdFlag = globalflags.ProjectIdFlag + + dnsCustomEndpointFlag = "dns-custom-endpoint" + membershipCustomEndpointFlag = "membership-custom-endpoint" + mongoDBFlexCustomEndpointFlag = "mongodbflex-custom-endpoint" + serviceAccountCustomEndpointFlag = "service-account-custom-endpoint" + skeCustomEndpointFlag = "ske-custom-endpoint" + resourceManagerCustomEndpointFlag = "resource-manager-custom-endpoint" +) + +type inputModel struct { + AsyncFlag bool + OutputFormat bool + ProjectId bool + + DNSCustomEndpoint bool + MembershipCustomEndpoint bool + MongoDBFlexCustomEndpoint bool + ServiceAccountCustomEndpoint bool + SKECustomEndpoint bool + ResourceManagerCustomEndpoint bool +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "unset", + Short: "Unset CLI configuration options", + Long: "Unset CLI configuration options", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Unset the project ID stored in your configuration`, + "$ stackit config unset --project-id"), + examples.NewExample( + `Unset the session time limit stored in your configuration`, + "$ stackit config unset --session-time-limit"), + examples.NewExample( + `Unset the DNS custom endpoint stored in your configuration`, + "$ stackit config unset --dns-custom-endpoint"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + model := parseInput(cmd) + + if model.AsyncFlag { + viper.Set(config.AsyncKey, "") + } + if model.OutputFormat { + viper.Set(config.OutputFormatKey, "") + } + if model.ProjectId { + viper.Set(config.ProjectIdKey, "") + } + + if model.DNSCustomEndpoint { + viper.Set(config.DNSCustomEndpointKey, "") + } + if model.MembershipCustomEndpoint { + viper.Set(config.MembershipCustomEndpointKey, "") + } + if model.MongoDBFlexCustomEndpoint { + viper.Set(config.MongoDBFlexCustomEndpointKey, "") + } + if model.ServiceAccountCustomEndpoint { + viper.Set(config.ServiceAccountCustomEndpointKey, "") + } + if model.SKECustomEndpoint { + viper.Set(config.SKECustomEndpointKey, "") + } + if model.ResourceManagerCustomEndpoint { + viper.Set(config.ResourceManagerEndpointKey, "") + } + + err := viper.WriteConfig() + if err != nil { + return fmt.Errorf("write updated config to file: %w", err) + } + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Bool(asyncFlag, false, "Configuration option to run commands asynchronously") + cmd.Flags().Bool(projectIdFlag, false, "Project ID") + cmd.Flags().Bool(outputFormatFlag, false, "Output format") + + cmd.Flags().Bool(dnsCustomEndpointFlag, false, "DNS custom endpoint") + cmd.Flags().Bool(membershipCustomEndpointFlag, false, "Membership custom endpoint") + cmd.Flags().Bool(mongoDBFlexCustomEndpointFlag, false, "MongoDB Flex custom endpoint") + cmd.Flags().Bool(serviceAccountCustomEndpointFlag, false, "SKE custom endpoint") + cmd.Flags().Bool(skeCustomEndpointFlag, false, "SKE custom endpoint") + cmd.Flags().Bool(resourceManagerCustomEndpointFlag, false, "Resource Manager custom endpoint") +} + +func parseInput(cmd *cobra.Command) *inputModel { + return &inputModel{ + AsyncFlag: flags.FlagToBoolValue(cmd, asyncFlag), + OutputFormat: flags.FlagToBoolValue(cmd, outputFormatFlag), + ProjectId: flags.FlagToBoolValue(cmd, projectIdFlag), + + DNSCustomEndpoint: flags.FlagToBoolValue(cmd, dnsCustomEndpointFlag), + MembershipCustomEndpoint: flags.FlagToBoolValue(cmd, membershipCustomEndpointFlag), + MongoDBFlexCustomEndpoint: flags.FlagToBoolValue(cmd, mongoDBFlexCustomEndpointFlag), + ServiceAccountCustomEndpoint: flags.FlagToBoolValue(cmd, serviceAccountCustomEndpointFlag), + SKECustomEndpoint: flags.FlagToBoolValue(cmd, skeCustomEndpointFlag), + ResourceManagerCustomEndpoint: flags.FlagToBoolValue(cmd, resourceManagerCustomEndpointFlag), + } +} diff --git a/internal/cmd/config/unset/unset_test.go b/internal/cmd/config/unset/unset_test.go new file mode 100644 index 00000000..38342062 --- /dev/null +++ b/internal/cmd/config/unset/unset_test.go @@ -0,0 +1,161 @@ +package unset + +import ( + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func fixtureFlagValues(mods ...func(flagValues map[string]bool)) map[string]bool { + flagValues := map[string]bool{ + projectIdFlag: true, + outputFormatFlag: true, + dnsCustomEndpointFlag: true, + serviceAccountCustomEndpointFlag: true, + skeCustomEndpointFlag: true, + resourceManagerCustomEndpointFlag: true, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + ProjectId: true, + OutputFormat: true, + DNSCustomEndpoint: true, + ServiceAccountCustomEndpoint: true, + SKECustomEndpoint: true, + ResourceManagerCustomEndpoint: true, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]bool + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + flagValues: map[string]bool{}, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.ProjectId = false + model.OutputFormat = false + model.DNSCustomEndpoint = false + model.ServiceAccountCustomEndpoint = false + model.SKECustomEndpoint = false + model.ResourceManagerCustomEndpoint = false + }), + }, + { + description: "project id empty", + flagValues: fixtureFlagValues(func(flagValues map[string]bool) { + flagValues[projectIdFlag] = false + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.ProjectId = false + }), + }, + { + description: "output format empty", + flagValues: fixtureFlagValues(func(flagValues map[string]bool) { + flagValues[outputFormatFlag] = false + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.OutputFormat = false + }), + }, + { + description: "dns custom endpoint empty", + flagValues: fixtureFlagValues(func(flagValues map[string]bool) { + flagValues[dnsCustomEndpointFlag] = false + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.DNSCustomEndpoint = false + }), + }, + { + description: "service account custom endpoint empty", + flagValues: fixtureFlagValues(func(flagValues map[string]bool) { + flagValues[serviceAccountCustomEndpointFlag] = false + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.ServiceAccountCustomEndpoint = false + }), + }, + { + description: "ske custom endpoint empty", + flagValues: fixtureFlagValues(func(flagValues map[string]bool) { + flagValues[skeCustomEndpointFlag] = false + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.SKECustomEndpoint = false + }), + }, + { + description: "resource manager custom endpoint empty", + flagValues: fixtureFlagValues(func(flagValues map[string]bool) { + flagValues[resourceManagerCustomEndpointFlag] = false + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.ResourceManagerCustomEndpoint = false + }), + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + + for flag, value := range tt.flagValues { + stringBool := fmt.Sprintf("%v", value) + err := cmd.Flags().Set(flag, stringBool) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, stringBool, err) + } + } + + err := cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model := parseInput(cmd) + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/curl/curl.go b/internal/cmd/curl/curl.go new file mode 100644 index 00000000..3a55f2e5 --- /dev/null +++ b/internal/cmd/curl/curl.go @@ -0,0 +1,227 @@ +package curl + +import ( + "bytes" + "fmt" + "io" + "net/http" + "net/http/httputil" + "net/url" + "os" + "strings" + "time" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/auth" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/flags" + + "github.com/spf13/cobra" +) + +const ( + requestMethodFlag = "request" + headerFlag = "header" + dataFlag = "data" + includeResponseHeadersFlag = "include" + failOnHTTPErrorFlag = "fail" + outputFileFlag = "output" +) + +const ( + urlArg = "URL" +) + +type inputModel struct { + URL string + RequestMethod string + Headers []string + Data *string + IncludeResponseHeaders bool + FailOnHTTPError bool + OutputFile *string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("curl %s", urlArg), + Short: "Execute an authenticated HTTP request to an endpoint", + Long: "Execute an HTTP request to an endpoint, using the authentication provided by the CLI", + Example: examples.Build( + examples.NewExample( + "Make a GET request to http://locahost:8000", + "$ stackit curl http://locahost:8000", + ), + examples.NewExample( + `Make a GET request to http://locahost:8000, write complete response (headers and body) to file "./output.txt"`, + "$ stackit curl http://locahost:8000 -include --output ./output.txt", + ), + examples.NewExample( + `Make a POST request to http://locahost:8000 with payload from file "./payload.json"`, + `$ stackit curl http://locahost:8000 -X POST --data @./payload.json`, + ), + examples.NewExample( + `Make a POST request to http://locahost:8000 with header "Foo: Bar", fail if server returns error (such as 403 Forbidden)`, + `$ stackit curl http://locahost:8000 -X POST -H "Foo: Bar" --fail`, + ), + ), + Args: args.SingleArg(urlArg, validateURL), + RunE: func(cmd *cobra.Command, args []string) (err error) { + model, err := parseInput(cmd, args) + if err != nil { + return err + } + + bearerToken, err := getBearerToken(cmd) + if err != nil { + return err + } + + req, err := buildRequest(model, bearerToken) + if err != nil { + return err + } + + client := http.Client{ + Timeout: 30 * time.Second, + } + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("do request: %w", err) + } + defer func() { + closeErr := resp.Body.Close() + if closeErr != nil { + err = fmt.Errorf("close response body: %w", closeErr) + } + }() + + err = outputResponse(cmd, model, resp) + if err != nil { + return err + } + + if model.FailOnHTTPError && resp.StatusCode >= 400 { + os.Exit(22) + } + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func validateURL(value string) error { + urlStruct, err := url.Parse(value) + if err != nil { + return fmt.Errorf("parse URL: %w", err) + } + urlHost := urlStruct.Hostname() + if urlHost == "" { + return fmt.Errorf("bad url") + } + if !strings.HasSuffix(urlHost, "stackit.cloud") { + return fmt.Errorf("only urls belonging to STACKIT are permitted") + } + return nil +} + +func configureFlags(cmd *cobra.Command) { + requestMethodOptions := []string{ + http.MethodGet, + http.MethodHead, + http.MethodPost, + http.MethodPut, + http.MethodPatch, + http.MethodDelete, + http.MethodConnect, + http.MethodOptions, + http.MethodTrace, + } + headerFlagUsage := `Custom headers to include in the request, can be specified multiple times. If the "Authorization" header is set, it will override the authentication provided by the CLI` + + cmd.Flags().VarP(flags.EnumFlag(true, "", requestMethodOptions...), requestMethodFlag, "X", "HTTP method, defaults to GET") + cmd.Flags().StringSliceP(headerFlag, "H", []string{}, headerFlagUsage) + cmd.Flags().Var(flags.ReadFromFileFlag(), dataFlag, `Content to include in the request body. Can be a string or a file path prefixed with "@"`) + cmd.Flags().Bool(includeResponseHeadersFlag, false, "If set, response headers are added to the output") + cmd.Flags().Bool(failOnHTTPErrorFlag, false, "If set, exits with error 22 if response code is 4XX or 5XX") + cmd.Flags().String(outputFileFlag, "", "Writes output to provided file instead of printing to console") +} + +func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + urlString := inputArgs[0] + requestMethod := flags.FlagToStringValue(cmd, requestMethodFlag) + if requestMethod == "" { + requestMethod = http.MethodGet + } + + return &inputModel{ + URL: urlString, + RequestMethod: strings.ToUpper(requestMethod), + Headers: flags.FlagToStringSliceValue(cmd, headerFlag), + Data: flags.FlagToStringPointer(cmd, dataFlag), + IncludeResponseHeaders: flags.FlagToBoolValue(cmd, includeResponseHeadersFlag), + FailOnHTTPError: flags.FlagToBoolValue(cmd, failOnHTTPErrorFlag), + OutputFile: flags.FlagToStringPointer(cmd, outputFileFlag), + }, nil +} + +func getBearerToken(cmd *cobra.Command) (string, error) { + _, err := auth.AuthenticationConfig(cmd, auth.AuthorizeUser) + if err != nil { + return "", &errors.AuthError{} + } + token, err := auth.GetAuthField(auth.ACCESS_TOKEN) + if err != nil { + return "", fmt.Errorf("get access token: %w", err) + } + return token, nil +} + +func buildRequest(model *inputModel, bearerToken string) (*http.Request, error) { + var body io.Reader = http.NoBody + if model.Data != nil { + body = bytes.NewBufferString(*model.Data) + } + req, err := http.NewRequest(model.RequestMethod, model.URL, body) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", bearerToken)) + for _, header := range model.Headers { + headerSplit := strings.SplitN(header, ": ", 2) + if len(headerSplit) != 2 { + return nil, fmt.Errorf("badly formatted header %q", header) + } + req.Header.Set(headerSplit[0], headerSplit[1]) + } + return req, nil +} + +func outputResponse(cmd *cobra.Command, model *inputModel, resp *http.Response) error { + output := make([]byte, 0) + if model.IncludeResponseHeaders { + respHeader, err := httputil.DumpResponse(resp, false) + if err != nil { + return fmt.Errorf("print response headers: %w", err) + } + output = append(output, respHeader...) + } + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read respose body: %w", err) + } + output = append(output, respBody...) + + if model.OutputFile == nil { + cmd.Println(string(output)) + } else { + err = os.WriteFile(*model.OutputFile, output, 0o600) + if err != nil { + return fmt.Errorf("write output to file: %w", err) + } + } + + return nil +} diff --git a/internal/cmd/curl/curl_test.go b/internal/cmd/curl/curl_test.go new file mode 100644 index 00000000..94482a69 --- /dev/null +++ b/internal/cmd/curl/curl_test.go @@ -0,0 +1,392 @@ +package curl + +import ( + "bytes" + "context" + "fmt" + "net/http" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/utils" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" +) + +var testURL = "https://some-service.api.stackit.cloud/v1/foo?bar=baz" +var testToken = "auth-token" + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testURL, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + requestMethodFlag: "post", + headerFlag: "Test-header-1: Test value 1", + dataFlag: "data", + includeResponseHeadersFlag: "true", + failOnHTTPErrorFlag: "true", + outputFileFlag: "path/to/output.txt", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + URL: testURL, + RequestMethod: "POST", + Headers: []string{"Test-header-1: Test value 1"}, + Data: utils.Ptr("data"), + IncludeResponseHeaders: true, + FailOnHTTPError: true, + OutputFile: utils.Ptr("path/to/output.txt"), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *http.Request)) *http.Request { + req, err := http.NewRequest("POST", testURL, bytes.NewBufferString("data")) + req.Header.Set("Test-header-1", "Test value 1") + if err != nil { + panic(err) + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", testToken)) + for _, mod := range mods { + mod(req) + } + return req +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + headerFlagValues []string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: true, + expectedModel: &inputModel{ + URL: testURL, + RequestMethod: "GET", + }, + }, + { + description: "invalid URL 1", + argValues: []string{ + "", + }, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "invalid URL 2", + argValues: []string{ + "foo", + }, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "URL outside STACKIT", + argValues: []string{ + "https://www.very-suspicious-website.com/", + }, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "invalid method 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[requestMethodFlag] = "" + }), + isValid: false, + }, + { + description: "invalid method 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[requestMethodFlag] = "foo" + }), + isValid: false, + }, + { + description: "invalid method 3", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[requestMethodFlag] = " GET" + }), + isValid: false, + }, + { + description: "valid method 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[requestMethodFlag] = "put" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.RequestMethod = "PUT" + }), + }, + { + description: "valid method 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[requestMethodFlag] = "pAtCh" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.RequestMethod = "PATCH" + }), + }, + { + description: "repeated header flags", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + headerFlagValues: []string{"Test-header-2: Test value 2", "Test-header-3: Test value 3"}, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Headers = append( + model.Headers, + "Test-header-2: Test value 2", + "Test-header-3: Test value 3", + ) + }), + }, + { + description: "repeated header flags with list value", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + headerFlagValues: []string{"Test-header-2: Test value 2,Test-header-3: Test value 3"}, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Headers = append( + model.Headers, + "Test-header-2: Test value 2", + "Test-header-3: Test value 3", + ) + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + for _, value := range tt.headerFlagValues { + err := cmd.Flags().Set(headerFlag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", headerFlag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing input: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + defaultReq, err := http.NewRequest("GET", testURL, http.NoBody) + if err != nil { + t.Fatalf("failed to create new request: %v", err) + } + defaultReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", testToken)) + + tests := []struct { + description string + model *inputModel + isValid bool + expectedRequest *http.Request + }{ + { + description: "base", + model: fixtureInputModel(), + isValid: true, + expectedRequest: fixtureRequest(), + }, + { + description: "default values", + model: &inputModel{ + URL: testURL, + RequestMethod: "GET", + }, + isValid: true, + expectedRequest: defaultReq, + }, + { + description: "invalid header 1", + model: fixtureInputModel(func(model *inputModel) { + model.Headers = append(model.Headers, "foo") + }), + isValid: false, + }, + { + description: "invalid header 2", + model: fixtureInputModel(func(model *inputModel) { + model.Headers = append(model.Headers, "foo bar") + }), + isValid: false, + }, + { + description: "invalid header 3", + model: fixtureInputModel(func(model *inputModel) { + model.Headers = append(model.Headers, "foo:") + }), + isValid: false, + }, + { + description: "extra headers 1", + model: fixtureInputModel(func(model *inputModel) { + model.Headers = append( + model.Headers, + "Test-header-2: Test value 2", + "Test-header-3: Test value 3", + ) + }), + isValid: true, + expectedRequest: fixtureRequest(func(request *http.Request) { + request.Header.Set("Test-header-2", "Test value 2") + request.Header.Set("Test-header-3", "Test value 3") + }), + }, + { + description: "extra headers 2", + model: fixtureInputModel(func(model *inputModel) { + model.Headers = append( + model.Headers, + "Test-header-2: Test value 2", + "Test-header-3: Test value 3", + "Test-header-2: Test value 4", + ) + }), + isValid: true, + expectedRequest: fixtureRequest(func(request *http.Request) { + request.Header.Set("Test-header-2", "Test value 4") + request.Header.Set("Test-header-3", "Test value 3") + }), + }, + { + description: "extra headers 3", + model: fixtureInputModel(func(model *inputModel) { + model.Headers = append( + model.Headers, + "Test-header-2: Test value 2", + "Test-header-3: Test value 3", + "Authorization: Test value 4", + ) + }), + isValid: true, + expectedRequest: fixtureRequest(func(request *http.Request) { + request.Header.Set("Test-header-2", "Test value 2") + request.Header.Set("Test-header-3", "Test value 3") + request.Header.Set("Authorization", "Test value 4") + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request, err := buildRequest(tt.model, testToken) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error building request: %v", err) + } + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(http.Request{}), + cmpopts.IgnoreFields(http.Request{}, "GetBody"), // Function, not relevant for the test + cmp.Comparer(func(x, y *bytes.Buffer) bool { // Used to compare request bodies + xBytes := x.Bytes() + yBytes := y.Bytes() + return bytes.Equal(xBytes, yBytes) + }), + cmpopts.EquateComparable(context.Background()), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/dns/dns.go b/internal/cmd/dns/dns.go new file mode 100644 index 00000000..fac451ae --- /dev/null +++ b/internal/cmd/dns/dns.go @@ -0,0 +1,27 @@ +package dns + +import ( + recordset "stackit/internal/cmd/dns/record-set" + "stackit/internal/cmd/dns/zone" + "stackit/internal/pkg/args" + "stackit/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "dns", + Short: "Provides functionality for DNS", + Long: "Provides functionality for DNS", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd) + return cmd +} + +func addSubcommands(cmd *cobra.Command) { + cmd.AddCommand(zone.NewCmd()) + cmd.AddCommand(recordset.NewCmd()) +} diff --git a/internal/cmd/dns/record-set/create/create.go b/internal/cmd/dns/record-set/create/create.go new file mode 100644 index 00000000..819a2100 --- /dev/null +++ b/internal/cmd/dns/record-set/create/create.go @@ -0,0 +1,158 @@ +package create + +import ( + "context" + "fmt" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/confirm" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/flags" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/services/dns/client" + dnsUtils "stackit/internal/pkg/services/dns/utils" + "stackit/internal/pkg/spinner" + "stackit/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/dns" + "github.com/stackitcloud/stackit-sdk-go/services/dns/wait" +) + +const ( + zoneIdFlag = "zone-id" + commentFlag = "comment" + nameFlag = "name" + recordFlag = "record" + ttlFlag = "ttl" + typeFlag = "type" + + defaultType = "A" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ZoneId string + Comment *string + Name *string + Records []string + TTL *int64 + Type string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates a DNS record set", + Long: "Creates a DNS record set", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create a DNS record set with name "my-rr" with records "1.2.3.4" and "5.6.7.8" in zone with ID "xxx"`, + "$ stackit dns record-set create --zone-id xxx --name my-rr --record 1.2.3.4 --record 5.6.7.8"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + zoneLabel, err := dnsUtils.GetZoneName(ctx, apiClient, model.ProjectId, model.ZoneId) + if err != nil { + zoneLabel = model.ZoneId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to create a record set for zone %s?", zoneLabel) + err = confirm.PromptForConfirmation(cmd, prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create DNS record set: %w", err) + } + recordSetId := *resp.Rrset.Id + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(cmd) + s.Start("Creating record set") + _, err = wait.CreateRecordSetWaitHandler(ctx, apiClient, model.ProjectId, model.ZoneId, recordSetId).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for DNS record set creation: %w", err) + } + s.Stop() + } + + operationState := "Created" + if model.Async { + operationState = "Triggered creation of" + } + cmd.Printf("%s record set for zone %s. Record set ID: %s\n", operationState, zoneLabel, recordSetId) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + typeFlagOptions := []string{"A", "AAAA", "SOA", "CNAME", "NS", "MX", "TXT", "SRV", "PTR", "ALIAS", "DNAME", "CAA"} + + cmd.Flags().Var(flags.UUIDFlag(), zoneIdFlag, "Zone ID") + cmd.Flags().String(commentFlag, "", "User comment") + cmd.Flags().String(nameFlag, "", "Name of the record, should be compliant with RFC1035, Section 2.3.4") + cmd.Flags().Int64(ttlFlag, 0, "Time to live, if not provided defaults to the zone's default TTL") + cmd.Flags().StringSlice(recordFlag, []string{}, "Records belonging to the record set") + cmd.Flags().Var(flags.EnumFlag(false, defaultType, typeFlagOptions...), typeFlag, fmt.Sprintf("Record type, one of %q", typeFlagOptions)) + + err := flags.MarkFlagsRequired(cmd, zoneIdFlag, nameFlag, recordFlag) + cobra.CheckErr(err) +} + +func parseInput(cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + ZoneId: flags.FlagToStringValue(cmd, zoneIdFlag), + Comment: flags.FlagToStringPointer(cmd, commentFlag), + Name: flags.FlagToStringPointer(cmd, nameFlag), + Records: flags.FlagToStringSliceValue(cmd, recordFlag), + TTL: flags.FlagToInt64Pointer(cmd, ttlFlag), + Type: flags.FlagWithDefaultToStringValue(cmd, typeFlag), + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *dns.APIClient) dns.ApiCreateRecordSetRequest { + records := make([]dns.RecordPayload, 0) + for _, r := range model.Records { + records = append(records, dns.RecordPayload{Content: utils.Ptr(r)}) + } + + req := apiClient.CreateRecordSet(ctx, model.ProjectId, model.ZoneId) + req = req.CreateRecordSetPayload(dns.CreateRecordSetPayload{ + Comment: model.Comment, + Name: model.Name, + Records: &records, + Ttl: model.TTL, + Type: &model.Type, + }) + return req +} diff --git a/internal/cmd/dns/record-set/create/create_test.go b/internal/cmd/dns/record-set/create/create_test.go new file mode 100644 index 00000000..d8269f95 --- /dev/null +++ b/internal/cmd/dns/record-set/create/create_test.go @@ -0,0 +1,338 @@ +package create + +import ( + "context" + "testing" + + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/dns" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &dns.APIClient{} +var testProjectId = uuid.NewString() +var testZoneId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + zoneIdFlag: testZoneId, + commentFlag: "comment", + nameFlag: "example.com", + recordFlag: "1.1.1.1", + ttlFlag: "3600", + typeFlag: "SOA", // Non-default value + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + ZoneId: testZoneId, + Name: utils.Ptr("example.com"), + Comment: utils.Ptr("comment"), + Records: []string{"1.1.1.1"}, + TTL: utils.Ptr(int64(3600)), + Type: "SOA", + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *dns.ApiCreateRecordSetRequest)) dns.ApiCreateRecordSetRequest { + request := testClient.CreateRecordSet(testCtx, testProjectId, testZoneId) + request = request.CreateRecordSetPayload(dns.CreateRecordSetPayload{ + Name: utils.Ptr("example.com"), + Comment: utils.Ptr("comment"), + Records: &[]dns.RecordPayload{ + {Content: utils.Ptr("1.1.1.1")}, + }, + Ttl: utils.Ptr(int64(3600)), + Type: utils.Ptr("SOA"), + }) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + recordFlagValues []string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "required fields only", + flagValues: map[string]string{ + projectIdFlag: testProjectId, + zoneIdFlag: testZoneId, + nameFlag: "example.com", + recordFlag: "1.1.1.1", + }, + isValid: true, + expectedModel: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + ZoneId: testZoneId, + Name: utils.Ptr("example.com"), + Records: []string{"1.1.1.1"}, + Type: defaultType, + }, + }, + { + description: "zero values", + flagValues: map[string]string{ + projectIdFlag: testProjectId, + zoneIdFlag: testZoneId, + commentFlag: "", + nameFlag: "", + recordFlag: "1.1.1.1", + ttlFlag: "0", + }, + isValid: true, + expectedModel: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + ZoneId: testZoneId, + Name: utils.Ptr(""), + Comment: utils.Ptr(""), + Records: []string{"1.1.1.1"}, + TTL: utils.Ptr(int64(0)), + Type: defaultType, + }, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "zone id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, zoneIdFlag) + }), + isValid: false, + }, + { + description: "zone id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[zoneIdFlag] = "" + }), + isValid: false, + }, + { + description: "zone id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[zoneIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "name missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, nameFlag) + }), + isValid: false, + }, + { + description: "records missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, recordFlag) + }), + isValid: false, + }, + { + description: "type missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, typeFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Type = defaultType + }), + }, + { + description: "type invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[typeFlag] = "" + }), + isValid: false, + }, + { + description: "type invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[typeFlag] = "a" + }), + isValid: false, + }, + { + description: "repeated primary flags", + flagValues: fixtureFlagValues(), + recordFlagValues: []string{"1.2.3.4", "5.6.7.8"}, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Records = append(model.Records, "1.2.3.4", "5.6.7.8") + }), + }, + { + description: "repeated primary flags with list value", + flagValues: fixtureFlagValues(), + recordFlagValues: []string{"1.2.3.4,5.6.7.8"}, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Records = append(model.Records, "1.2.3.4", "5.6.7.8") + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + for _, value := range tt.recordFlagValues { + err := cmd.Flags().Set(recordFlag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", recordFlag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest dns.ApiCreateRecordSetRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "required fields only", + model: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + ZoneId: testZoneId, + Name: utils.Ptr("example.com"), + Records: []string{"1.1.1.1"}, + Type: defaultType, + }, + expectedRequest: testClient.CreateRecordSet(testCtx, testProjectId, testZoneId). + CreateRecordSetPayload(dns.CreateRecordSetPayload{ + Name: utils.Ptr("example.com"), + Records: &[]dns.RecordPayload{ + {Content: utils.Ptr("1.1.1.1")}, + }, + Type: utils.Ptr(defaultType), + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/dns/record-set/delete/delete.go b/internal/cmd/dns/record-set/delete/delete.go new file mode 100644 index 00000000..4abe99e9 --- /dev/null +++ b/internal/cmd/dns/record-set/delete/delete.go @@ -0,0 +1,135 @@ +package delete + +import ( + "context" + "fmt" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/confirm" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/flags" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/services/dns/client" + dnsUtils "stackit/internal/pkg/services/dns/utils" + "stackit/internal/pkg/spinner" + "stackit/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/dns" + "github.com/stackitcloud/stackit-sdk-go/services/dns/wait" +) + +const ( + recordSetIdArg = "RECORD_SET_ID" + + zoneIdFlag = "zone-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ZoneId string + RecordSetId string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", recordSetIdArg), + Short: "Delete a DNS record set", + Long: "Delete a DNS record set", + Args: args.SingleArg(recordSetIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Delete DNS record set with ID "xxx" in zone with ID "yyy"`, + "$ stackit dns record-set delete xxx --zone-id yyy"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + zoneLabel, err := dnsUtils.GetZoneName(ctx, apiClient, model.ProjectId, model.ZoneId) + if err != nil { + zoneLabel = model.ZoneId + } + + recordSetLabel, err := dnsUtils.GetRecordSetName(ctx, apiClient, model.ProjectId, model.ZoneId, model.RecordSetId) + if err != nil { + recordSetLabel = model.RecordSetId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to delete record set %s of zone %s? (This cannot be undone)", recordSetLabel, zoneLabel) + err = confirm.PromptForConfirmation(cmd, prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + if err != nil { + return err + } + _, err = req.Execute() + if err != nil { + return fmt.Errorf("delete DNS record set: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(cmd) + s.Start("Deleting record set") + _, err = wait.DeleteRecordSetWaitHandler(ctx, apiClient, model.ProjectId, model.ZoneId, model.RecordSetId).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for DNS record set deletion: %w", err) + } + s.Stop() + } + + operationState := "Deleted" + if model.Async { + operationState = "Triggered deletion of" + } + cmd.Printf("%s record set %s of zone %s\n", operationState, recordSetLabel, zoneLabel) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), zoneIdFlag, "Zone ID") + + err := flags.MarkFlagsRequired(cmd, zoneIdFlag) + cobra.CheckErr(err) +} + +func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + recordSetId := inputArgs[0] + + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + ZoneId: flags.FlagToStringValue(cmd, zoneIdFlag), + RecordSetId: recordSetId, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *dns.APIClient) dns.ApiDeleteRecordSetRequest { + req := apiClient.DeleteRecordSet(ctx, model.ProjectId, model.ZoneId, model.RecordSetId) + return req +} diff --git a/internal/cmd/dns/record-set/delete/delete_test.go b/internal/cmd/dns/record-set/delete/delete_test.go new file mode 100644 index 00000000..38a00396 --- /dev/null +++ b/internal/cmd/dns/record-set/delete/delete_test.go @@ -0,0 +1,244 @@ +package delete + +import ( + "context" + "testing" + + "stackit/internal/pkg/globalflags" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/dns" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &dns.APIClient{} +var testProjectId = uuid.NewString() +var testZoneId = uuid.NewString() +var testRecordSetId = uuid.NewString() + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testRecordSetId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + zoneIdFlag: testZoneId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + ZoneId: testZoneId, + RecordSetId: testRecordSetId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *dns.ApiDeleteRecordSetRequest)) dns.ApiDeleteRecordSetRequest { + request := testClient.DeleteRecordSet(testCtx, testProjectId, testZoneId, testRecordSetId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "zone id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, zoneIdFlag) + }), + isValid: false, + }, + { + description: "zone id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[zoneIdFlag] = "" + }), + isValid: false, + }, + { + description: "zone id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[zoneIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "record set id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "record set id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing input: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + isValid bool + expectedRequest dns.ApiDeleteRecordSetRequest + }{ + { + description: "base", + model: fixtureInputModel(), + isValid: true, + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/dns/record-set/describe/describe.go b/internal/cmd/dns/record-set/describe/describe.go new file mode 100644 index 00000000..9af1c42b --- /dev/null +++ b/internal/cmd/dns/record-set/describe/describe.go @@ -0,0 +1,140 @@ +package describe + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/flags" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/services/dns/client" + "stackit/internal/pkg/tables" + "stackit/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/dns" +) + +const ( + recordSetIdArg = "RECORD_SET_ID" + + zoneIdFlag = "zone-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ZoneId string + RecordSetId string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", recordSetIdArg), + Short: "Get details of a DNS record set", + Long: "Get details of a DNS record set", + Args: args.SingleArg(recordSetIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Get details of DNS record set with ID "xxx" in zone with ID "yyy"`, + "$ stackit dns record-set describe xxx --zone-id yyy"), + examples.NewExample( + `Get details of DNS record set with ID "xxx" in zone with ID "yyy" in a table format`, + "$ stackit dns record-set describe xxx --zone-id yyy --output-format pretty"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("read DNS record set: %w", err) + } + recordSet := resp.Rrset + + return outputResult(cmd, model.OutputFormat, recordSet) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), zoneIdFlag, "Zone ID") + + err := flags.MarkFlagsRequired(cmd, zoneIdFlag) + cobra.CheckErr(err) +} + +func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + recordSetId := inputArgs[0] + + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + ZoneId: flags.FlagToStringValue(cmd, zoneIdFlag), + RecordSetId: recordSetId, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *dns.APIClient) dns.ApiGetRecordSetRequest { + req := apiClient.GetRecordSet(ctx, model.ProjectId, model.ZoneId, model.RecordSetId) + return req +} + +func outputResult(cmd *cobra.Command, outputFormat string, recordSet *dns.RecordSet) error { + switch outputFormat { + case globalflags.PrettyOutputFormat: + records := *recordSet.Records + recordsData := []string{} + for i := range records { + recordsData = append(recordsData, *records[i].Content) + } + recordsDataJoin := strings.Join(recordsData, ",") + + table := tables.NewTable() + table.AddRow("ID", *recordSet.Id) + table.AddSeparator() + table.AddRow("NAME", *recordSet.Name) + table.AddSeparator() + table.AddRow("STATE", *recordSet.State) + table.AddSeparator() + table.AddRow("TTL", *recordSet.Ttl) + table.AddSeparator() + table.AddRow("TYPE", *recordSet.Type) + table.AddSeparator() + table.AddRow("RECORDS DATA", recordsDataJoin) + err := table.Display(cmd) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + default: + details, err := json.MarshalIndent(recordSet, "", " ") + if err != nil { + return fmt.Errorf("marshal DNS record set: %w", err) + } + cmd.Println(string(details)) + + return nil + } +} diff --git a/internal/cmd/dns/record-set/describe/describe_test.go b/internal/cmd/dns/record-set/describe/describe_test.go new file mode 100644 index 00000000..a834ee5e --- /dev/null +++ b/internal/cmd/dns/record-set/describe/describe_test.go @@ -0,0 +1,244 @@ +package describe + +import ( + "context" + "testing" + + "stackit/internal/pkg/globalflags" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/dns" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &dns.APIClient{} +var testProjectId = uuid.NewString() +var testZoneId = uuid.NewString() +var testRecordSetId = uuid.NewString() + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testRecordSetId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + zoneIdFlag: testZoneId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + ZoneId: testZoneId, + RecordSetId: testRecordSetId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *dns.ApiGetRecordSetRequest)) dns.ApiGetRecordSetRequest { + request := testClient.GetRecordSet(testCtx, testProjectId, testZoneId, testRecordSetId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "zone id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, zoneIdFlag) + }), + isValid: false, + }, + { + description: "zone id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[zoneIdFlag] = "" + }), + isValid: false, + }, + { + description: "zone id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[zoneIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "record set id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "record set id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing input: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + isValid bool + expectedRequest dns.ApiGetRecordSetRequest + }{ + { + description: "base", + model: fixtureInputModel(), + isValid: true, + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/dns/record-set/list/list.go b/internal/cmd/dns/record-set/list/list.go new file mode 100644 index 00000000..d14c7f32 --- /dev/null +++ b/internal/cmd/dns/record-set/list/list.go @@ -0,0 +1,247 @@ +package list + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/flags" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/services/dns/client" + dnsUtils "stackit/internal/pkg/services/dns/utils" + "stackit/internal/pkg/tables" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/dns" +) + +const ( + activeFlag = "active" + inactiveFlag = "inactive" + zoneIdFlag = "zone-id" + deletedFlag = "deleted" + nameLikeFlag = "name-like" + orderByNameFlag = "order-by-name" + limitFlag = "limit" + pageSizeFlag = "page-size" + + pageSizeDefault = 100 + deleteSucceededState = "DELETE_SUCCEEDED" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + + Active bool + Inactive bool + ZoneId string + Deleted bool + NameLike *string + OrderByName *string + Limit *int64 + PageSize int64 +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List DNS record sets", + Long: `List DNS record sets. Successfully deleted record sets are not listed by default.`, + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List DNS record-sets for zone with ID "xxx"`, + "$ stackit dns record-set list --zone-id xxx"), + examples.NewExample( + `List DNS record-sets for zone with ID "xxx" in JSON format`, + "$ stackit dns record-set list --zone-id xxx --output-format json"), + examples.NewExample( + `List active DNS record-sets for zone with ID "xxx"`, + "$ stackit dns record-set list --zone-id xxx --is-active true"), + examples.NewExample( + `List up to 10 DNS record-sets for zone with ID "xxx"`, + "$ stackit dns record-set list --zone-id xxx --limit 10"), + examples.NewExample( + `List the deleted DNS record-sets for zone with ID "xxx"`, + "$ stackit dns record-set list --zone-id xxx --deleted"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + // Fetch record sets + recordSets, err := fetchRecordSets(ctx, model, apiClient) + if err != nil { + return err + } + if len(recordSets) == 0 { + zoneLabel, err := dnsUtils.GetZoneName(ctx, apiClient, model.ProjectId, model.ZoneId) + if err != nil { + zoneLabel = model.ZoneId + } + cmd.Printf("No record sets found for zone %s matching the criteria\n", zoneLabel) + return nil + } + return outputResult(cmd, model.OutputFormat, recordSets) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + orderByNameFlagOptions := []string{"asc", "desc"} + + cmd.Flags().Var(flags.UUIDFlag(), zoneIdFlag, "Zone ID") + cmd.Flags().Bool(activeFlag, false, "Filter for active record sets") + cmd.Flags().Bool(inactiveFlag, false, "Filter for inactive record sets. Deleted record sets are always inactive and will be included when this flag is set") + cmd.Flags().Bool(deletedFlag, false, "Filter for deleted record sets") + cmd.Flags().String(nameLikeFlag, "", "Filter by name") + cmd.Flags().Var(flags.EnumFlag(true, "", orderByNameFlagOptions...), orderByNameFlag, fmt.Sprintf("Order by name, one of %q", orderByNameFlagOptions)) + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") + cmd.Flags().Int64(pageSizeFlag, pageSizeDefault, "Number of items fetched in each API call. Does not affect the number of items in the command output") + + err := flags.MarkFlagsRequired(cmd, zoneIdFlag) + cobra.CheckErr(err) +} + +func parseInput(cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + limit := flags.FlagToInt64Pointer(cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + pageSize := flags.FlagWithDefaultToInt64Value(cmd, pageSizeFlag) + if pageSize < 1 { + return nil, &errors.FlagValidationError{ + Flag: pageSizeFlag, + Details: "must be greater than 0", + } + } + + active := flags.FlagToBoolValue(cmd, activeFlag) + inactive := flags.FlagToBoolValue(cmd, inactiveFlag) + if active && inactive { + return nil, fmt.Errorf("only one of %s and %s can be set", activeFlag, inactiveFlag) + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + ZoneId: flags.FlagToStringValue(cmd, zoneIdFlag), + Active: active, + Inactive: inactive, + Deleted: flags.FlagToBoolValue(cmd, deletedFlag), + NameLike: flags.FlagToStringPointer(cmd, nameLikeFlag), + OrderByName: flags.FlagToStringPointer(cmd, orderByNameFlag), + Limit: flags.FlagToInt64Pointer(cmd, limitFlag), + PageSize: pageSize, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient dnsClient, page int) dns.ApiListRecordSetsRequest { + req := apiClient.ListRecordSets(ctx, model.ProjectId, model.ZoneId) + if model.Active { + req = req.ActiveEq(true) + } + if model.Inactive { + req = req.ActiveEq(false) + } + if model.Deleted { + req = req.StateEq(deleteSucceededState) + } else if !model.Inactive { + req = req.StateNeq(deleteSucceededState) + } + if model.NameLike != nil { + req = req.NameLike(*model.NameLike) + } + if model.OrderByName != nil { + req = req.OrderByName(strings.ToUpper(*model.OrderByName)) + } + req = req.PageSize(int32(model.PageSize)) + req = req.Page(int32(page)) + return req +} + +type dnsClient interface { + ListRecordSets(ctx context.Context, projectId, zoneId string) dns.ApiListRecordSetsRequest +} + +func fetchRecordSets(ctx context.Context, model *inputModel, apiClient dnsClient) ([]dns.RecordSet, error) { + if model.Limit != nil && *model.Limit < model.PageSize { + model.PageSize = *model.Limit + } + page := 1 + recordSets := []dns.RecordSet{} + for { + // Call API + req := buildRequest(ctx, model, apiClient, page) + resp, err := req.Execute() + if err != nil { + return nil, fmt.Errorf("get DNS record sets: %w", err) + } + respRecordSets := *resp.RrSets + if len(respRecordSets) == 0 { + break + } + recordSets = append(recordSets, respRecordSets...) + // Stop if no more pages + if len(respRecordSets) < int(model.PageSize) { + break + } + // Stop and truncate if limit is reached + if model.Limit != nil && len(recordSets) >= int(*model.Limit) { + recordSets = recordSets[:*model.Limit] + break + } + page++ + } + return recordSets, nil +} + +func outputResult(cmd *cobra.Command, outputFormat string, recordSets []dns.RecordSet) error { + switch outputFormat { + case globalflags.JSONOutputFormat: + details, err := json.MarshalIndent(recordSets, "", " ") + if err != nil { + return fmt.Errorf("marshal DNS record set list: %w", err) + } + cmd.Println(string(details)) + + return nil + default: + table := tables.NewTable() + table.SetHeader("ID", "NAME", "STATUS", "TTL", "TYPE") + for i := range recordSets { + rs := recordSets[i] + table.AddRow(*rs.Id, *rs.Name, *rs.State, *rs.Ttl, *rs.Type) + } + err := table.Display(cmd) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + } +} diff --git a/internal/cmd/dns/record-set/list/list_test.go b/internal/cmd/dns/record-set/list/list_test.go new file mode 100644 index 00000000..75311992 --- /dev/null +++ b/internal/cmd/dns/record-set/list/list_test.go @@ -0,0 +1,498 @@ +package list + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/services/dns" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &dns.APIClient{} +var testProjectId = uuid.NewString() +var testZoneId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + zoneIdFlag: testZoneId, + nameLikeFlag: "some-pattern", + orderByNameFlag: "asc", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + ZoneId: testZoneId, + NameLike: utils.Ptr("some-pattern"), + OrderByName: utils.Ptr("asc"), + PageSize: pageSizeDefault, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *dns.ApiListRecordSetsRequest)) dns.ApiListRecordSetsRequest { + request := testClient.ListRecordSets(testCtx, testProjectId, testZoneId) + request = request.NameLike("some-pattern") + request = request.OrderByName("ASC") + request = request.PageSize(pageSizeDefault) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "deleted record sets", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[deletedFlag] = "true" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Deleted = true + }), + }, + { + description: "active record sets", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[activeFlag] = "true" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Active = true + }), + }, + { + description: "inactive record sets", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[inactiveFlag] = "true" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Inactive = true + }), + }, + { + description: "active and inactive record sets", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[activeFlag] = "true" + flagValues[inactiveFlag] = "true" + }), + isValid: false, + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "required fields only", + flagValues: map[string]string{ + projectIdFlag: testProjectId, + zoneIdFlag: testZoneId, + }, + isValid: true, + expectedModel: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + ZoneId: testZoneId, + PageSize: 100, // Default value + }, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "name like empty", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[nameLikeFlag] = "" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.NameLike = utils.Ptr("") + }), + }, + { + description: "order by name desc", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[orderByNameFlag] = "desc" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.OrderByName = utils.Ptr("desc") + }), + }, + { + description: "order by name invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[orderByNameFlag] = "" + }), + isValid: false, + }, + { + description: "order by name invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[orderByNameFlag] = "invalid" + }), + isValid: false, + }, + { + description: "limit invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "invalid" + }), + isValid: false, + }, + { + description: "limit invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + page int + expectedRequest dns.ApiListRecordSetsRequest + }{ + { + description: "base", + model: fixtureInputModel(), + page: 1, + expectedRequest: fixtureRequest().StateNeq(deleteSucceededState).Page(1), + }, + { + description: "base 2", + model: fixtureInputModel(), + page: 10, + expectedRequest: fixtureRequest().StateNeq(deleteSucceededState).Page(10), + }, + { + description: "deleted record sets", + model: fixtureInputModel(func(model *inputModel) { + model.Deleted = true + }), + page: 1, + expectedRequest: fixtureRequest().StateEq(deleteSucceededState).Page(1), + }, + { + description: "active record sets", + model: fixtureInputModel(func(model *inputModel) { + model.Active = true + }), + page: 1, + expectedRequest: fixtureRequest().ActiveEq(true).StateNeq(deleteSucceededState).Page(1), + }, + { + description: "inactive record sets", + model: fixtureInputModel(func(model *inputModel) { + model.Inactive = true + }), + page: 1, + expectedRequest: fixtureRequest().ActiveEq(false).Page(1), + }, + { + description: "required fields only", + model: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + ZoneId: testZoneId, + PageSize: 10, + }, + page: 1, + expectedRequest: testClient.ListRecordSets(testCtx, testProjectId, testZoneId).Page(1).PageSize(10).StateNeq(deleteSucceededState), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient, tt.page) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestFetchRecordSets(t *testing.T) { + tests := []struct { + description string + model *inputModel + totalItems int + apiCallFails bool + expectedNumAPICalls int + expectedNumItems int + }{ + { + description: "no limit and pageSize>totalItems", + model: fixtureInputModel(), + totalItems: 10, + expectedNumAPICalls: 1, + apiCallFails: false, + expectedNumItems: 10, + }, + { + description: "no limit and pageSizetotalItems and pageSize>totalItems", + model: fixtureInputModel(func(model *inputModel) { + model.Limit = utils.Ptr(int64(200)) + model.PageSize = 300 + }), + totalItems: 50, + expectedNumAPICalls: 1, + apiCallFails: false, + expectedNumItems: 50, + }, + { + description: "limit>totalItems and pageSize= tt.totalItems { + numItemsToReturn = 0 // Total items reached + } else if offset+pageSize < tt.totalItems { + numItemsToReturn = pageSize // Full intermediate page + } else { + numItemsToReturn = tt.totalItems - offset // Last page + } + + recordSets := make([]dns.RecordSet, numItemsToReturn) + mockedResp := dns.ListRecordSetsResponse{ + RrSets: &recordSets, + } + + mockedRespBytes, err := json.Marshal(mockedResp) + if err != nil { + t.Fatalf("Failed to marshal mocked response: %v", err) + } + + _, err = w.Write(mockedRespBytes) + if err != nil { + t.Errorf("Failed to write response: %v", err) + } + }) + mockedServer := httptest.NewServer(handler) + defer mockedServer.Close() + client, err := dns.NewAPIClient( + sdkConfig.WithEndpoint(mockedServer.URL), + sdkConfig.WithoutAuthentication(), + ) + if err != nil { + t.Fatalf("Failed to initialize client: %v", err) + } + + recordSets, err := fetchRecordSets(testCtx, tt.model, client) + if err != nil { + if !tt.apiCallFails { + t.Fatalf("did not fail on invalid input") + } + return + } + if err == nil && tt.apiCallFails { + t.Fatalf("did not fail on invalid input") + } + if numAPICalls != tt.expectedNumAPICalls { + t.Fatalf("Expected %d API calls, got %d", tt.expectedNumAPICalls, numAPICalls) + } + if len(recordSets) != tt.expectedNumItems { + t.Fatalf("Expected %d recordSets, got %d", tt.totalItems, len(recordSets)) + } + }) + } +} diff --git a/internal/cmd/dns/record-set/record_set.go b/internal/cmd/dns/record-set/record_set.go new file mode 100644 index 00000000..bb4458ab --- /dev/null +++ b/internal/cmd/dns/record-set/record_set.go @@ -0,0 +1,33 @@ +package recordset + +import ( + "stackit/internal/cmd/dns/record-set/create" + "stackit/internal/cmd/dns/record-set/delete" + "stackit/internal/cmd/dns/record-set/describe" + "stackit/internal/cmd/dns/record-set/list" + "stackit/internal/cmd/dns/record-set/update" + "stackit/internal/pkg/args" + "stackit/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "record-set", + Short: "Provides functionality for DNS record set", + Long: "Provides functionality for DNS record set", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd) + return cmd +} + +func addSubcommands(cmd *cobra.Command) { + cmd.AddCommand(list.NewCmd()) + cmd.AddCommand(create.NewCmd()) + cmd.AddCommand(describe.NewCmd()) + cmd.AddCommand(delete.NewCmd()) + cmd.AddCommand(update.NewCmd()) +} diff --git a/internal/cmd/dns/record-set/update/update.go b/internal/cmd/dns/record-set/update/update.go new file mode 100644 index 00000000..e645998d --- /dev/null +++ b/internal/cmd/dns/record-set/update/update.go @@ -0,0 +1,172 @@ +package update + +import ( + "context" + "fmt" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/confirm" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/flags" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/services/dns/client" + dnsUtils "stackit/internal/pkg/services/dns/utils" + "stackit/internal/pkg/spinner" + "stackit/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/dns" + "github.com/stackitcloud/stackit-sdk-go/services/dns/wait" +) + +const ( + recordSetIdArg = "RECORD_SET_ID" + + zoneIdFlag = "zone-id" + commentFlag = "comment" + nameFlag = "name" + recordFlag = "record" + ttlFlag = "ttl" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ZoneId string + RecordSetId string + Comment *string + Name *string + Records *[]string + TTL *int64 +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("update %s", recordSetIdArg), + Short: "Updates a DNS record set", + Long: "Updates a DNS record set. Performs a partial update; fields not provided are kept unchanged", + Args: args.SingleArg(recordSetIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Update the time to live of the record-set with ID "xxx" for zone with ID "yyy"`, + "$ stackit dns record-set update xxx --zone-id yyy --ttl 100"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + zoneLabel, err := dnsUtils.GetZoneName(ctx, apiClient, model.ProjectId, model.ZoneId) + if err != nil { + zoneLabel = model.ZoneId + } + + recordSetLabel, err := dnsUtils.GetRecordSetName(ctx, apiClient, model.ProjectId, model.ZoneId, model.RecordSetId) + if err != nil { + recordSetLabel = model.RecordSetId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to update record set %s of zone %s?", recordSetLabel, zoneLabel) + err = confirm.PromptForConfirmation(cmd, prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + _, err = req.Execute() + if err != nil { + return fmt.Errorf("update DNS record set: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(cmd) + s.Start("Updating record set") + _, err = wait.PartialUpdateRecordSetWaitHandler(ctx, apiClient, model.ProjectId, model.ZoneId, model.RecordSetId).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for DNS record set update: %w", err) + } + s.Stop() + } + + operationState := "Updated" + if model.Async { + operationState = "Triggered update of" + } + cmd.Printf("%s record set %s of zone %s\n", operationState, recordSetLabel, zoneLabel) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), zoneIdFlag, "Zone ID") + cmd.Flags().String(commentFlag, "", "User comment") + cmd.Flags().String(nameFlag, "", "Name of the record, should be compliant with RFC1035, Section 2.3.4") + cmd.Flags().Int64(ttlFlag, 0, "Time to live, if not provided defaults to the zone's default TTL") + cmd.Flags().StringSlice(recordFlag, []string{}, "Records belonging to the record set. If this flag is used, records already created that aren't set when running the command will be deleted") + + err := flags.MarkFlagsRequired(cmd, zoneIdFlag) + cobra.CheckErr(err) +} + +func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + recordSetId := inputArgs[0] + + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + zoneId := flags.FlagToStringValue(cmd, zoneIdFlag) + comment := flags.FlagToStringPointer(cmd, commentFlag) + name := flags.FlagToStringPointer(cmd, nameFlag) + records := flags.FlagToStringSlicePointer(cmd, recordFlag) + ttl := flags.FlagToInt64Pointer(cmd, ttlFlag) + + if comment == nil && name == nil && records == nil && ttl == nil { + return nil, &errors.EmptyUpdateError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + ZoneId: zoneId, + RecordSetId: recordSetId, + Comment: comment, + Name: name, + Records: records, + TTL: ttl, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *dns.APIClient) dns.ApiPartialUpdateRecordSetRequest { + var records *[]dns.RecordPayload = nil + if model.Records != nil { + records = utils.Ptr(make([]dns.RecordPayload, 0)) + for _, r := range *model.Records { + records = utils.Ptr(append(*records, dns.RecordPayload{Content: utils.Ptr(r)})) + } + } + + req := apiClient.PartialUpdateRecordSet(ctx, model.ProjectId, model.ZoneId, model.RecordSetId) + req = req.PartialUpdateRecordSetPayload(dns.PartialUpdateRecordSetPayload{ + Comment: model.Comment, + Name: model.Name, + Records: records, + Ttl: model.TTL, + }) + return req +} diff --git a/internal/cmd/dns/record-set/update/update_test.go b/internal/cmd/dns/record-set/update/update_test.go new file mode 100644 index 00000000..e0c041b8 --- /dev/null +++ b/internal/cmd/dns/record-set/update/update_test.go @@ -0,0 +1,342 @@ +package update + +import ( + "context" + "testing" + + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/dns" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &dns.APIClient{} +var testProjectId = uuid.NewString() +var testZoneId = uuid.NewString() +var testRecordSetId = uuid.NewString() + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testRecordSetId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + zoneIdFlag: testZoneId, + commentFlag: "comment", + nameFlag: "example.com", + recordFlag: "1.1.1.1", + ttlFlag: "3600", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + ZoneId: testZoneId, + RecordSetId: testRecordSetId, + Name: utils.Ptr("example.com"), + Comment: utils.Ptr("comment"), + Records: &[]string{"1.1.1.1"}, + TTL: utils.Ptr(int64(3600)), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *dns.ApiPartialUpdateRecordSetRequest)) dns.ApiPartialUpdateRecordSetRequest { + request := testClient.PartialUpdateRecordSet(testCtx, testProjectId, testZoneId, testRecordSetId) + request = request.PartialUpdateRecordSetPayload(dns.PartialUpdateRecordSetPayload{ + Name: utils.Ptr("example.com"), + Comment: utils.Ptr("comment"), + Records: &[]dns.RecordPayload{ + {Content: utils.Ptr("1.1.1.1")}, + }, + Ttl: utils.Ptr(int64(3600)), + }) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + recordFlagValues []string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "required flags only (no values to update)", + argValues: fixtureArgValues(), + flagValues: map[string]string{ + projectIdFlag: testProjectId, + zoneIdFlag: testZoneId, + }, + isValid: false, + expectedModel: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + ZoneId: testZoneId, + RecordSetId: testRecordSetId, + }, + }, + { + description: "zero values", + argValues: fixtureArgValues(), + flagValues: map[string]string{ + projectIdFlag: testProjectId, + zoneIdFlag: testZoneId, + commentFlag: "", + nameFlag: "", + recordFlag: "1.1.1.1", + ttlFlag: "0", + }, + isValid: true, + expectedModel: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + ZoneId: testZoneId, + RecordSetId: testRecordSetId, + Name: utils.Ptr(""), + Comment: utils.Ptr(""), + Records: &[]string{"1.1.1.1"}, + TTL: utils.Ptr(int64(0)), + }, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "zone id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, zoneIdFlag) + }), + isValid: false, + }, + { + description: "zone id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[zoneIdFlag] = "" + }), + isValid: false, + }, + { + description: "zone id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[zoneIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "record set id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "record set id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "repeated primary flags", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + recordFlagValues: []string{"1.2.3.4", "5.6.7.8"}, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Records = utils.Ptr(append(*model.Records, "1.2.3.4", "5.6.7.8")) + }), + }, + { + description: "repeated primary flags with list value", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + recordFlagValues: []string{"1.2.3.4,5.6.7.8"}, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Records = utils.Ptr(append(*model.Records, "1.2.3.4", "5.6.7.8")) + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + for _, value := range tt.recordFlagValues { + err := cmd.Flags().Set(recordFlag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", recordFlag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing input: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest dns.ApiPartialUpdateRecordSetRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "required fields only", + model: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + ZoneId: testZoneId, + RecordSetId: testRecordSetId, + }, + expectedRequest: testClient.PartialUpdateRecordSet(testCtx, testProjectId, testZoneId, testRecordSetId). + PartialUpdateRecordSetPayload(dns.PartialUpdateRecordSetPayload{}), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/dns/zone/create/create.go b/internal/cmd/dns/zone/create/create.go new file mode 100644 index 00000000..93321637 --- /dev/null +++ b/internal/cmd/dns/zone/create/create.go @@ -0,0 +1,187 @@ +package create + +import ( + "context" + "fmt" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/confirm" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/flags" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/projectname" + "stackit/internal/pkg/services/dns/client" + "stackit/internal/pkg/spinner" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/dns" + "github.com/stackitcloud/stackit-sdk-go/services/dns/wait" +) + +const ( + nameFlag = "name" + dnsNameFlag = "dns-name" + defaultTTLFlag = "default-ttl" + primaryFlag = "primary" + aclFlag = "acl" + typeFlag = "type" + retryTimeFlag = "retry-time" + refreshTimeFlag = "refresh-time" + negativeCacheFlag = "negative-cache" + isReverseZoneFlag = "is-reverse-zone" + expireTimeFlag = "expire-time" + descriptionFlag = "description" + contactEmailFlag = "contact-email" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Name *string + DnsName *string + DefaultTTL *int64 + Primaries *[]string + Acl *string + Type *string + RetryTime *int64 + RefreshTime *int64 + NegativeCache *int64 + IsReverseZone *bool + ExpireTime *int64 + Description *string + ContactEmail *string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates a DNS zone", + Long: "Creates a DNS zone", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create a DNS zone with name "my-zone" and DNS name "www.my-zone.com"`, + "$ stackit dns zone create --name my-zone --dns-name www.my-zone.com"), + examples.NewExample( + `Create a DNS zone with name "my-zone", DNS name "www.my-zone.com" and default time to live of 1000ms`, + "$ stackit dns zone create --name my-zone --dns-name www.my-zone.com --default-ttl 1000"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, cmd) + if err != nil { + projectLabel = model.ProjectId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to create a zone for project %s?", projectLabel) + err = confirm.PromptForConfirmation(cmd, prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create DNS zone: %w", err) + } + zoneId := *resp.Zone.Id + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(cmd) + s.Start("Creating zone") + _, err = wait.CreateZoneWaitHandler(ctx, apiClient, model.ProjectId, zoneId).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for DNS zone creation: %w", err) + } + s.Stop() + } + + operationState := "Created" + if model.Async { + operationState = "Triggered creation of" + } + cmd.Printf("%s zone for project %s. Zone ID: %s\n", operationState, projectLabel, zoneId) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(nameFlag, "", "User given name of the zone") + cmd.Flags().String(dnsNameFlag, "", "Fully qualified domain name of the DNS zone") + cmd.Flags().Int64(defaultTTLFlag, 1000, "Default time to live") + cmd.Flags().StringSlice(primaryFlag, []string{}, "Primary name server for secondary zone") + cmd.Flags().String(aclFlag, "", "Access control list") + cmd.Flags().String(typeFlag, "", "Zone type") + cmd.Flags().Int64(retryTimeFlag, 0, "Retry time") + cmd.Flags().Int64(refreshTimeFlag, 0, "Refresh time") + cmd.Flags().Int64(negativeCacheFlag, 0, "Negative cache") + cmd.Flags().Bool(isReverseZoneFlag, false, "Is reverse zone") + cmd.Flags().Int64(expireTimeFlag, 0, "Expire time") + cmd.Flags().String(descriptionFlag, "", "Description of the zone") + cmd.Flags().String(contactEmailFlag, "", "Contact email for the zone") + + err := flags.MarkFlagsRequired(cmd, nameFlag, dnsNameFlag) + cobra.CheckErr(err) +} + +func parseInput(cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + Name: flags.FlagToStringPointer(cmd, nameFlag), + DnsName: flags.FlagToStringPointer(cmd, dnsNameFlag), + DefaultTTL: flags.FlagToInt64Pointer(cmd, defaultTTLFlag), + Primaries: flags.FlagToStringSlicePointer(cmd, primaryFlag), + Acl: flags.FlagToStringPointer(cmd, aclFlag), + Type: flags.FlagToStringPointer(cmd, typeFlag), + RetryTime: flags.FlagToInt64Pointer(cmd, retryTimeFlag), + RefreshTime: flags.FlagToInt64Pointer(cmd, refreshTimeFlag), + NegativeCache: flags.FlagToInt64Pointer(cmd, negativeCacheFlag), + IsReverseZone: flags.FlagToBoolPointer(cmd, isReverseZoneFlag), + ExpireTime: flags.FlagToInt64Pointer(cmd, expireTimeFlag), + Description: flags.FlagToStringPointer(cmd, descriptionFlag), + ContactEmail: flags.FlagToStringPointer(cmd, contactEmailFlag), + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *dns.APIClient) dns.ApiCreateZoneRequest { + req := apiClient.CreateZone(ctx, model.ProjectId) + req = req.CreateZonePayload(dns.CreateZonePayload{ + Name: model.Name, + DnsName: model.DnsName, + DefaultTTL: model.DefaultTTL, + Primaries: model.Primaries, + Acl: model.Acl, + Type: model.Type, + RetryTime: model.RetryTime, + RefreshTime: model.RefreshTime, + NegativeCache: model.NegativeCache, + IsReverseZone: model.IsReverseZone, + ExpireTime: model.ExpireTime, + Description: model.Description, + ContactEmail: model.ContactEmail, + }) + return req +} diff --git a/internal/cmd/dns/zone/create/create_test.go b/internal/cmd/dns/zone/create/create_test.go new file mode 100644 index 00000000..21caf8bf --- /dev/null +++ b/internal/cmd/dns/zone/create/create_test.go @@ -0,0 +1,309 @@ +package create + +import ( + "context" + "testing" + + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/dns" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &dns.APIClient{} +var testProjectId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + nameFlag: "example", + dnsNameFlag: "example.com", + defaultTTLFlag: "3600", + aclFlag: "0.0.0.0/0", + typeFlag: "master", + primaryFlag: "1.1.1.1", + retryTimeFlag: "600", + refreshTimeFlag: "3600", + negativeCacheFlag: "60", + isReverseZoneFlag: "false", + expireTimeFlag: "36000000", + descriptionFlag: "Example", + contactEmailFlag: "example@example.com", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + Name: utils.Ptr("example"), + DnsName: utils.Ptr("example.com"), + DefaultTTL: utils.Ptr(int64(3600)), + Primaries: utils.Ptr([]string{"1.1.1.1"}), + Acl: utils.Ptr("0.0.0.0/0"), + Type: utils.Ptr("master"), + RetryTime: utils.Ptr(int64(600)), + RefreshTime: utils.Ptr(int64(3600)), + NegativeCache: utils.Ptr(int64(60)), + IsReverseZone: utils.Ptr(false), + ExpireTime: utils.Ptr(int64(36000000)), + Description: utils.Ptr("Example"), + ContactEmail: utils.Ptr("example@example.com"), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *dns.ApiCreateZoneRequest)) dns.ApiCreateZoneRequest { + request := testClient.CreateZone(testCtx, testProjectId) + request = request.CreateZonePayload(dns.CreateZonePayload{ + Name: utils.Ptr("example"), + DnsName: utils.Ptr("example.com"), + DefaultTTL: utils.Ptr(int64(3600)), + Primaries: utils.Ptr([]string{"1.1.1.1"}), + Acl: utils.Ptr("0.0.0.0/0"), + Type: utils.Ptr("master"), + RetryTime: utils.Ptr(int64(600)), + RefreshTime: utils.Ptr(int64(3600)), + NegativeCache: utils.Ptr(int64(60)), + IsReverseZone: utils.Ptr(false), + ExpireTime: utils.Ptr(int64(36000000)), + Description: utils.Ptr("Example"), + ContactEmail: utils.Ptr("example@example.com"), + }) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + primaryFlagValues []string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "required fields only", + flagValues: map[string]string{ + projectIdFlag: testProjectId, + nameFlag: "example", + dnsNameFlag: "example.com", + }, + isValid: true, + expectedModel: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + Name: utils.Ptr("example"), + DnsName: utils.Ptr("example.com"), + }, + }, + { + description: "zero values", + flagValues: map[string]string{ + projectIdFlag: testProjectId, + nameFlag: "", + dnsNameFlag: "", + defaultTTLFlag: "0", + aclFlag: "", + typeFlag: "", + primaryFlag: "", + retryTimeFlag: "0", + refreshTimeFlag: "0", + negativeCacheFlag: "0", + isReverseZoneFlag: "false", + expireTimeFlag: "0", + descriptionFlag: "", + contactEmailFlag: "", + }, + isValid: true, + expectedModel: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + Name: utils.Ptr(""), + DnsName: utils.Ptr(""), + DefaultTTL: utils.Ptr(int64(0)), + Primaries: utils.Ptr([]string{}), + Acl: utils.Ptr(""), + Type: utils.Ptr(""), + RetryTime: utils.Ptr(int64(0)), + RefreshTime: utils.Ptr(int64(0)), + NegativeCache: utils.Ptr(int64(0)), + IsReverseZone: utils.Ptr(false), + ExpireTime: utils.Ptr(int64(0)), + Description: utils.Ptr(""), + ContactEmail: utils.Ptr(""), + }, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "repeated primary flags", + flagValues: fixtureFlagValues(), + primaryFlagValues: []string{"1.2.3.4", "5.6.7.8"}, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Primaries = utils.Ptr( + append(*model.Primaries, "1.2.3.4", "5.6.7.8"), + ) + }), + }, + { + description: "repeated primary flags with list value", + flagValues: fixtureFlagValues(), + primaryFlagValues: []string{"1.2.3.4,5.6.7.8"}, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Primaries = utils.Ptr( + append(*model.Primaries, "1.2.3.4", "5.6.7.8"), + ) + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + for _, value := range tt.primaryFlagValues { + err := cmd.Flags().Set(primaryFlag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", primaryFlag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest dns.ApiCreateZoneRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "required fields only", + model: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + Name: utils.Ptr("example"), + DnsName: utils.Ptr("example.com"), + }, + expectedRequest: testClient.CreateZone(testCtx, testProjectId). + CreateZonePayload(dns.CreateZonePayload{ + Name: utils.Ptr("example"), + DnsName: utils.Ptr("example.com"), + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/dns/zone/delete/delete.go b/internal/cmd/dns/zone/delete/delete.go new file mode 100644 index 00000000..58e5525b --- /dev/null +++ b/internal/cmd/dns/zone/delete/delete.go @@ -0,0 +1,115 @@ +package delete + +import ( + "context" + "fmt" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/confirm" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/services/dns/client" + dnsUtils "stackit/internal/pkg/services/dns/utils" + "stackit/internal/pkg/spinner" + "stackit/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/dns" + "github.com/stackitcloud/stackit-sdk-go/services/dns/wait" +) + +const ( + zoneIdArg = "ZONE_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ZoneId string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", zoneIdArg), + Short: "Delete a DNS zone", + Long: "Delete a DNS zone", + Args: args.SingleArg(zoneIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Delete a DNS zone with ID "xxx"`, + "$ stackit dns zone delete xxx"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + zoneLabel, err := dnsUtils.GetZoneName(ctx, apiClient, model.ProjectId, model.ZoneId) + if err != nil { + zoneLabel = model.ZoneId + } + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to delete zone %s? (This cannot be undone)", zoneLabel) + err = confirm.PromptForConfirmation(cmd, prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + if err != nil { + return err + } + _, err = req.Execute() + if err != nil { + return fmt.Errorf("delete DNS zone: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(cmd) + s.Start("Deleting zone") + _, err = wait.DeleteZoneWaitHandler(ctx, apiClient, model.ProjectId, model.ZoneId).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for DNS zone deletion: %w", err) + } + s.Stop() + } + + operationState := "Deleted" + if model.Async { + operationState = "Triggered deletion of" + } + cmd.Printf("%s zone %s\n", operationState, zoneLabel) + return nil + }, + } + return cmd +} + +func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + zoneId := inputArgs[0] + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + ZoneId: zoneId, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *dns.APIClient) dns.ApiDeleteZoneRequest { + req := apiClient.DeleteZone(ctx, model.ProjectId, model.ZoneId) + return req +} diff --git a/internal/cmd/dns/zone/delete/delete_test.go b/internal/cmd/dns/zone/delete/delete_test.go new file mode 100644 index 00000000..f7fd2ca2 --- /dev/null +++ b/internal/cmd/dns/zone/delete/delete_test.go @@ -0,0 +1,217 @@ +package delete + +import ( + "context" + "testing" + + "stackit/internal/pkg/globalflags" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/dns" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &dns.APIClient{} +var testProjectId = uuid.NewString() +var testZoneId = uuid.NewString() + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testZoneId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + ZoneId: testZoneId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *dns.ApiDeleteZoneRequest)) dns.ApiDeleteZoneRequest { + request := testClient.DeleteZone(testCtx, testProjectId, testZoneId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "zone id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "zone id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing input: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + isValid bool + expectedRequest dns.ApiDeleteZoneRequest + }{ + { + description: "base", + model: fixtureInputModel(), + isValid: true, + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/dns/zone/describe/describe.go b/internal/cmd/dns/zone/describe/describe.go new file mode 100644 index 00000000..9d24c4c0 --- /dev/null +++ b/internal/cmd/dns/zone/describe/describe.go @@ -0,0 +1,136 @@ +package describe + +import ( + "context" + "encoding/json" + "fmt" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/services/dns/client" + "stackit/internal/pkg/tables" + "stackit/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/dns" +) + +const ( + zoneIdArg = "ZONE_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ZoneId string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", zoneIdArg), + Short: "Get details of a DNS zone", + Long: "Get details of a DNS zone", + Args: args.SingleArg(zoneIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Get details of a DNS zone with ID "xxx"`, + "$ stackit dns zone describe xxx"), + examples.NewExample( + `Get details of a DNS zone with ID "xxx" in a table format`, + "$ stackit dns zone describe xxx --output-format pretty"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd, args) + if err != nil { + return err + } + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("read DNS zone: %w", err) + } + zone := resp.Zone + + return outputResult(cmd, model.OutputFormat, zone) + }, + } + return cmd +} + +func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + zoneId := inputArgs[0] + + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + ZoneId: zoneId, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *dns.APIClient) dns.ApiGetZoneRequest { + req := apiClient.GetZone(ctx, model.ProjectId, model.ZoneId) + return req +} + +func outputResult(cmd *cobra.Command, outputFormat string, zone *dns.Zone) error { + switch outputFormat { + case globalflags.PrettyOutputFormat: + table := tables.NewTable() + table.AddRow("ID", *zone.Id) + table.AddSeparator() + table.AddRow("NAME", *zone.Name) + table.AddSeparator() + table.AddRow("DESCRIPTION", *zone.Description) + table.AddSeparator() + table.AddRow("STATE", *zone.State) + table.AddSeparator() + table.AddRow("TYPE", *zone.Type) + table.AddSeparator() + table.AddRow("DNS NAME", *zone.DnsName) + table.AddSeparator() + table.AddRow("REVERSE ZONE", *zone.IsReverseZone) + table.AddSeparator() + table.AddRow("RECORD COUNT", *zone.RecordCount) + table.AddSeparator() + table.AddRow("CONTACT EMAIL", *zone.ContactEmail) + table.AddSeparator() + table.AddRow("DEFAULT TTL", *zone.DefaultTTL) + table.AddSeparator() + table.AddRow("SERIAL NUMBER", *zone.SerialNumber) + table.AddSeparator() + table.AddRow("REFRESH TIME", *zone.RefreshTime) + table.AddSeparator() + table.AddRow("RETRY TIME", *zone.RetryTime) + table.AddSeparator() + table.AddRow("EXPIRE TIME", *zone.ExpireTime) + table.AddSeparator() + table.AddRow("NEGATIVE CACHE", *zone.NegativeCache) + err := table.Display(cmd) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + default: + details, err := json.MarshalIndent(zone, "", " ") + if err != nil { + return fmt.Errorf("marshal DNS zone: %w", err) + } + cmd.Println(string(details)) + + return nil + } +} diff --git a/internal/cmd/dns/zone/describe/describe_test.go b/internal/cmd/dns/zone/describe/describe_test.go new file mode 100644 index 00000000..531f3789 --- /dev/null +++ b/internal/cmd/dns/zone/describe/describe_test.go @@ -0,0 +1,217 @@ +package describe + +import ( + "context" + "testing" + + "stackit/internal/pkg/globalflags" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/dns" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &dns.APIClient{} +var testProjectId = uuid.NewString() +var testZoneId = uuid.NewString() + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testZoneId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + ZoneId: testZoneId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *dns.ApiGetZoneRequest)) dns.ApiGetZoneRequest { + request := testClient.GetZone(testCtx, testProjectId, testZoneId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "zone id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "zone id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing input: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + isValid bool + expectedRequest dns.ApiGetZoneRequest + }{ + { + description: "base", + model: fixtureInputModel(), + isValid: true, + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/dns/zone/list/list.go b/internal/cmd/dns/zone/list/list.go new file mode 100644 index 00000000..88660de6 --- /dev/null +++ b/internal/cmd/dns/zone/list/list.go @@ -0,0 +1,238 @@ +package list + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/flags" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/projectname" + "stackit/internal/pkg/services/dns/client" + "stackit/internal/pkg/tables" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/dns" +) + +const ( + activeFlag = "active" + inactiveFlag = "inactive" + deletedFlag = "deleted" + nameLikeFlag = "name-like" + orderByNameFlag = "order-by-name" + limitFlag = "limit" + pageSizeFlag = "page-size" + + pageSizeDefault = 100 + deleteSucceededState = "DELETE_SUCCEEDED" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + + Active bool + Inactive bool + Deleted bool + NameLike *string + OrderByName *string + Limit *int64 + PageSize int64 +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List DNS zones", + Long: `List DNS zones. Successfully deleted zones are not listed by default.`, + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List DNS zones`, + "$ stackit dns zone list"), + examples.NewExample( + `List DNS zones in JSON format`, + "$ stackit dns zone list --output-format json"), + examples.NewExample( + `List up to 10 DNS zones`, + "$ stackit dns zone list --limit 10"), + examples.NewExample( + `List the deleted DNS zones`, + "$ stackit dns zone list --deleted"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + // Fetch zones + zones, err := fetchZones(ctx, model, apiClient) + if err != nil { + return err + } + if len(zones) == 0 { + projectLabel, err := projectname.GetProjectName(ctx, cmd) + if err != nil { + projectLabel = model.ProjectId + } + cmd.Printf("No zones found for project %s matching the criteria\n", projectLabel) + return nil + } + + return outputResult(cmd, model.OutputFormat, zones) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + orderByNameFlagOptions := []string{"asc", "desc"} + + cmd.Flags().Bool(activeFlag, false, "Filter for active zones") + cmd.Flags().Bool(inactiveFlag, false, "Filter for inactive zones. Deleted zones are always inactive and will be included when this flag is set") + cmd.Flags().Bool(deletedFlag, false, "Filter for deleted zones") + cmd.Flags().String(nameLikeFlag, "", "Filter by name") + cmd.Flags().Var(flags.EnumFlag(true, "", orderByNameFlagOptions...), orderByNameFlag, fmt.Sprintf("Order by name, one of %q", orderByNameFlagOptions)) + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") + cmd.Flags().Int64(pageSizeFlag, pageSizeDefault, "Number of items fetched in each API call. Does not affect the number of items in the command output") +} + +func parseInput(cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + limit := flags.FlagToInt64Pointer(cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + pageSize := flags.FlagWithDefaultToInt64Value(cmd, pageSizeFlag) + if pageSize < 1 { + return nil, &errors.FlagValidationError{ + Flag: pageSizeFlag, + Details: "must be greater than 0", + } + } + + active := flags.FlagToBoolValue(cmd, activeFlag) + inactive := flags.FlagToBoolValue(cmd, inactiveFlag) + if active && inactive { + return nil, fmt.Errorf("only one of %s and %s can be set", activeFlag, inactiveFlag) + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + Active: active, + Inactive: inactive, + Deleted: flags.FlagToBoolValue(cmd, deletedFlag), + NameLike: flags.FlagToStringPointer(cmd, nameLikeFlag), + OrderByName: flags.FlagToStringPointer(cmd, orderByNameFlag), + Limit: limit, + PageSize: pageSize, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient dnsClient, page int) dns.ApiListZonesRequest { + req := apiClient.ListZones(ctx, model.ProjectId) + if model.Active { + req = req.ActiveEq(true) + } + if model.Inactive { + req = req.ActiveEq(false) + } + if model.Deleted { + req = req.StateEq(deleteSucceededState) + } else if !model.Inactive { + req = req.StateNeq(deleteSucceededState) + } + if model.NameLike != nil { + req = req.NameLike(*model.NameLike) + } + if model.OrderByName != nil { + req = req.OrderByName(strings.ToUpper(*model.OrderByName)) + } + req = req.PageSize(int32(model.PageSize)) + req = req.Page(int32(page)) + return req +} + +type dnsClient interface { + ListZones(ctx context.Context, projectId string) dns.ApiListZonesRequest +} + +func fetchZones(ctx context.Context, model *inputModel, apiClient dnsClient) ([]dns.Zone, error) { + if model.Limit != nil && *model.Limit < model.PageSize { + model.PageSize = *model.Limit + } + page := 1 + zones := []dns.Zone{} + for { + // Call API + req := buildRequest(ctx, model, apiClient, page) + resp, err := req.Execute() + if err != nil { + return nil, fmt.Errorf("get DNS zones: %w", err) + } + respZones := *resp.Zones + if len(respZones) == 0 { + break + } + zones = append(zones, respZones...) + // Stop if no more pages + if len(respZones) < int(model.PageSize) { + break + } + // Stop and truncate if limit is reached + if model.Limit != nil && len(zones) >= int(*model.Limit) { + zones = zones[:*model.Limit] + break + } + page++ + } + return zones, nil +} + +func outputResult(cmd *cobra.Command, outputFormat string, zones []dns.Zone) error { + switch outputFormat { + case globalflags.JSONOutputFormat: + // Show details + details, err := json.MarshalIndent(zones, "", " ") + if err != nil { + return fmt.Errorf("marshal DNS zone list: %w", err) + } + cmd.Println(string(details)) + + return nil + default: + table := tables.NewTable() + table.SetHeader("ID", "NAME", "STATE", "TYPE", "DNS NAME", "RECORD COUNT") + for i := range zones { + z := zones[i] + table.AddRow(*z.Id, *z.Name, *z.State, *z.Type, *z.DnsName, *z.RecordCount) + } + err := table.Display(cmd) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + } +} diff --git a/internal/cmd/dns/zone/list/list_test.go b/internal/cmd/dns/zone/list/list_test.go new file mode 100644 index 00000000..59099d68 --- /dev/null +++ b/internal/cmd/dns/zone/list/list_test.go @@ -0,0 +1,492 @@ +package list + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/services/dns" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &dns.APIClient{} +var testProjectId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + nameLikeFlag: "some-pattern", + orderByNameFlag: "asc", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + NameLike: utils.Ptr("some-pattern"), + OrderByName: utils.Ptr("asc"), + PageSize: pageSizeDefault, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *dns.ApiListZonesRequest)) dns.ApiListZonesRequest { + request := testClient.ListZones(testCtx, testProjectId) + request = request.NameLike("some-pattern") + request = request.OrderByName("ASC") + request = request.PageSize(pageSizeDefault) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "deleted zones", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[deletedFlag] = "true" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Deleted = true + }), + }, + { + description: "active zones", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[activeFlag] = "true" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Active = true + }), + }, + { + description: "inactive zones", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[inactiveFlag] = "true" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Inactive = true + }), + }, + { + description: "active and inactive zones", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[activeFlag] = "true" + flagValues[inactiveFlag] = "true" + }), + isValid: false, + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "required fields only", + flagValues: map[string]string{ + projectIdFlag: testProjectId, + }, + isValid: true, + expectedModel: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + PageSize: pageSizeDefault, + }, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "name like empty", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[nameLikeFlag] = "" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.NameLike = utils.Ptr("") + }), + }, + { + description: "order by name desc", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[orderByNameFlag] = "desc" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.OrderByName = utils.Ptr("desc") + }), + }, + { + description: "order by name invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[orderByNameFlag] = "" + }), + isValid: false, + }, + { + description: "order by name invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[orderByNameFlag] = "invalid" + }), + isValid: false, + }, + { + description: "limit invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "invalid" + }), + isValid: false, + }, + { + description: "limit invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + page int + expectedRequest dns.ApiListZonesRequest + }{ + { + description: "base", + model: fixtureInputModel(), + page: 1, + expectedRequest: fixtureRequest().StateNeq(deleteSucceededState).Page(1), + }, + { + description: "base 2", + model: fixtureInputModel(), + page: 10, + expectedRequest: fixtureRequest().StateNeq(deleteSucceededState).Page(10), + }, + { + description: "deleted zones", + model: fixtureInputModel(func(model *inputModel) { + model.Deleted = true + }), + page: 1, + expectedRequest: fixtureRequest().StateEq(deleteSucceededState).Page(1), + }, + { + description: "active zones", + model: fixtureInputModel(func(model *inputModel) { + model.Active = true + }), + page: 1, + expectedRequest: fixtureRequest().ActiveEq(true).StateNeq(deleteSucceededState).Page(1), + }, + { + description: "inactive zones", + model: fixtureInputModel(func(model *inputModel) { + model.Inactive = true + }), + page: 1, + expectedRequest: fixtureRequest().ActiveEq(false).Page(1), + }, + { + description: "required fields only", + model: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + PageSize: pageSizeDefault, + }, + page: 1, + expectedRequest: testClient.ListZones(testCtx, testProjectId).Page(1).PageSize(pageSizeDefault).StateNeq(deleteSucceededState), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient, tt.page) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestFetchZones(t *testing.T) { + tests := []struct { + description string + model *inputModel + totalItems int + apiCallFails bool + expectedNumAPICalls int + expectedNumItems int + }{ + { + description: "no limit and pageSize>totalItems", + model: fixtureInputModel(), + totalItems: 10, + expectedNumAPICalls: 1, + apiCallFails: false, + expectedNumItems: 10, + }, + { + description: "no limit and pageSizetotalItems and pageSize>totalItems", + model: fixtureInputModel(func(model *inputModel) { + model.Limit = utils.Ptr(int64(200)) + model.PageSize = 300 + }), + totalItems: 50, + expectedNumAPICalls: 1, + apiCallFails: false, + expectedNumItems: 50, + }, + { + description: "limit>totalItems and pageSize= tt.totalItems { + numItemsToReturn = 0 // Total items reached + } else if offset+pageSize < tt.totalItems { + numItemsToReturn = pageSize // Full intermediate page + } else { + numItemsToReturn = tt.totalItems - offset // Last page + } + + zones := make([]dns.Zone, numItemsToReturn) + mockedResp := dns.ListZonesResponse{ + Zones: &zones, + } + + mockedRespBytes, err := json.Marshal(mockedResp) + if err != nil { + t.Fatalf("Failed to marshal mocked response: %v", err) + } + + _, err = w.Write(mockedRespBytes) + if err != nil { + t.Errorf("Failed to write response: %v", err) + } + }) + mockedServer := httptest.NewServer(handler) + defer mockedServer.Close() + client, err := dns.NewAPIClient( + sdkConfig.WithEndpoint(mockedServer.URL), + sdkConfig.WithoutAuthentication(), + ) + if err != nil { + t.Fatalf("Failed to initialize client: %v", err) + } + + zones, err := fetchZones(testCtx, tt.model, client) + if err != nil { + if !tt.apiCallFails { + t.Fatalf("did not fail on invalid input") + } + return + } + if err == nil && tt.apiCallFails { + t.Fatalf("did not fail on invalid input") + } + if numAPICalls != tt.expectedNumAPICalls { + t.Fatalf("Expected %d API calls, got %d", tt.expectedNumAPICalls, numAPICalls) + } + if len(zones) != tt.expectedNumItems { + t.Fatalf("Expected %d zones, got %d", tt.totalItems, len(zones)) + } + }) + } +} diff --git a/internal/cmd/dns/zone/update/update.go b/internal/cmd/dns/zone/update/update.go new file mode 100644 index 00000000..d7f3d029 --- /dev/null +++ b/internal/cmd/dns/zone/update/update.go @@ -0,0 +1,193 @@ +package update + +import ( + "context" + "fmt" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/confirm" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/flags" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/services/dns/client" + dnsUtils "stackit/internal/pkg/services/dns/utils" + "stackit/internal/pkg/spinner" + "stackit/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/dns" + "github.com/stackitcloud/stackit-sdk-go/services/dns/wait" +) + +const ( + zoneIdArg = "ZONE_ID" + + nameFlag = "name" + defaultTTLFlag = "default-ttl" + primaryFlag = "primary" + aclFlag = "acl" + retryTimeFlag = "retry-time" + refreshTimeFlag = "refresh-time" + negativeCacheFlag = "negative-cache" + expireTimeFlag = "expire-time" + descriptionFlag = "description" + contactEmailFlag = "contact-email" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ZoneId string + Name *string + DefaultTTL *int64 + Primaries *[]string + Acl *string + RetryTime *int64 + RefreshTime *int64 + NegativeCache *int64 + ExpireTime *int64 + Description *string + ContactEmail *string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("update %s", zoneIdArg), + Short: "Updates a DNS zone", + Long: "Updates a DNS zone. Performs a partial update; fields not provided are kept unchanged", + Args: args.SingleArg(zoneIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Update the contact email of the DNS zone with ID "xxx"`, + "$ stackit dns zone update xxx --contact-email someone@domain.com"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + zoneLabel, err := dnsUtils.GetZoneName(ctx, apiClient, model.ProjectId, model.ZoneId) + if err != nil { + zoneLabel = model.ZoneId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to update zone %s?", zoneLabel) + err = confirm.PromptForConfirmation(cmd, prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + if err != nil { + return err + } + _, err = req.Execute() + if err != nil { + return fmt.Errorf("update DNS zone: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(cmd) + s.Start("Updating zone") + _, err = wait.PartialUpdateZoneWaitHandler(ctx, apiClient, model.ProjectId, model.ZoneId).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for DNS zone update: %w", err) + } + s.Stop() + } + + operationState := "Updated" + if model.Async { + operationState = "Triggered update of" + } + cmd.Printf("%s zone %s\n", operationState, zoneLabel) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(nameFlag, "", "User given name of the zone") + cmd.Flags().Int64(defaultTTLFlag, 1000, "Default time to live") + cmd.Flags().StringSlice(primaryFlag, []string{}, "Primary name server for secondary zone") + cmd.Flags().String(aclFlag, "", "Access control list") + cmd.Flags().Int64(retryTimeFlag, 0, "Retry time") + cmd.Flags().Int64(refreshTimeFlag, 0, "Refresh time") + cmd.Flags().Int64(negativeCacheFlag, 0, "Negative cache") + cmd.Flags().Int64(expireTimeFlag, 0, "Expire time") + cmd.Flags().String(descriptionFlag, "", "Description of the zone") + cmd.Flags().String(contactEmailFlag, "", "Contact email for the zone") +} + +func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + zoneId := inputArgs[0] + + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + name := flags.FlagToStringPointer(cmd, nameFlag) + defaultTTL := flags.FlagToInt64Pointer(cmd, defaultTTLFlag) + primaries := flags.FlagToStringSlicePointer(cmd, primaryFlag) + acl := flags.FlagToStringPointer(cmd, aclFlag) + retryTime := flags.FlagToInt64Pointer(cmd, retryTimeFlag) + refreshTime := flags.FlagToInt64Pointer(cmd, refreshTimeFlag) + negativeCache := flags.FlagToInt64Pointer(cmd, negativeCacheFlag) + expireTime := flags.FlagToInt64Pointer(cmd, expireTimeFlag) + description := flags.FlagToStringPointer(cmd, descriptionFlag) + contactEmail := flags.FlagToStringPointer(cmd, contactEmailFlag) + + if name == nil && defaultTTL == nil && primaries == nil && + acl == nil && retryTime == nil && refreshTime == nil && + negativeCache == nil && expireTime == nil && description == nil && + contactEmail == nil { + return nil, &errors.EmptyUpdateError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + ZoneId: zoneId, + Name: name, + DefaultTTL: defaultTTL, + Primaries: primaries, + Acl: acl, + RetryTime: retryTime, + RefreshTime: refreshTime, + NegativeCache: negativeCache, + ExpireTime: expireTime, + Description: description, + ContactEmail: contactEmail, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *dns.APIClient) dns.ApiPartialUpdateZoneRequest { + req := apiClient.PartialUpdateZone(ctx, model.ProjectId, model.ZoneId) + req = req.PartialUpdateZonePayload(dns.PartialUpdateZonePayload{ + Name: model.Name, + DefaultTTL: model.DefaultTTL, + Primaries: model.Primaries, + Acl: model.Acl, + RetryTime: model.RetryTime, + RefreshTime: model.RefreshTime, + NegativeCache: model.NegativeCache, + ExpireTime: model.ExpireTime, + Description: model.Description, + ContactEmail: model.ContactEmail, + }) + return req +} diff --git a/internal/cmd/dns/zone/update/update_test.go b/internal/cmd/dns/zone/update/update_test.go new file mode 100644 index 00000000..2019eaca --- /dev/null +++ b/internal/cmd/dns/zone/update/update_test.go @@ -0,0 +1,342 @@ +package update + +import ( + "context" + "testing" + + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/dns" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &dns.APIClient{} +var testProjectId = uuid.NewString() +var testZoneId = uuid.NewString() + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testZoneId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + nameFlag: "example", + defaultTTLFlag: "3600", + aclFlag: "0.0.0.0/0", + primaryFlag: "1.1.1.1", + retryTimeFlag: "600", + refreshTimeFlag: "3600", + negativeCacheFlag: "60", + expireTimeFlag: "36000000", + descriptionFlag: "Example", + contactEmailFlag: "example@example.com", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + ZoneId: testZoneId, + Name: utils.Ptr("example"), + DefaultTTL: utils.Ptr(int64(3600)), + Primaries: utils.Ptr([]string{"1.1.1.1"}), + Acl: utils.Ptr("0.0.0.0/0"), + RetryTime: utils.Ptr(int64(600)), + RefreshTime: utils.Ptr(int64(3600)), + NegativeCache: utils.Ptr(int64(60)), + ExpireTime: utils.Ptr(int64(36000000)), + Description: utils.Ptr("Example"), + ContactEmail: utils.Ptr("example@example.com"), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *dns.ApiPartialUpdateZoneRequest)) dns.ApiPartialUpdateZoneRequest { + request := testClient.PartialUpdateZone(testCtx, testProjectId, testZoneId) + request = request.PartialUpdateZonePayload(dns.PartialUpdateZonePayload{ + Name: utils.Ptr("example"), + DefaultTTL: utils.Ptr(int64(3600)), + Primaries: utils.Ptr([]string{"1.1.1.1"}), + Acl: utils.Ptr("0.0.0.0/0"), + RetryTime: utils.Ptr(int64(600)), + RefreshTime: utils.Ptr(int64(3600)), + NegativeCache: utils.Ptr(int64(60)), + ExpireTime: utils.Ptr(int64(36000000)), + Description: utils.Ptr("Example"), + ContactEmail: utils.Ptr("example@example.com"), + }) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + primaryFlagValues []string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "required flags only (no values to update)", + argValues: fixtureArgValues(), + flagValues: map[string]string{ + projectIdFlag: testProjectId, + }, + isValid: false, + expectedModel: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + ZoneId: testZoneId, + }, + }, + { + description: "zero values", + argValues: fixtureArgValues(), + flagValues: map[string]string{ + projectIdFlag: testProjectId, + nameFlag: "", + defaultTTLFlag: "0", + aclFlag: "", + primaryFlag: "", + retryTimeFlag: "0", + refreshTimeFlag: "0", + negativeCacheFlag: "0", + expireTimeFlag: "0", + descriptionFlag: "", + contactEmailFlag: "", + }, + isValid: true, + expectedModel: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + ZoneId: testZoneId, + Name: utils.Ptr(""), + DefaultTTL: utils.Ptr(int64(0)), + Primaries: utils.Ptr([]string{}), + Acl: utils.Ptr(""), + RetryTime: utils.Ptr(int64(0)), + RefreshTime: utils.Ptr(int64(0)), + NegativeCache: utils.Ptr(int64(0)), + ExpireTime: utils.Ptr(int64(0)), + Description: utils.Ptr(""), + ContactEmail: utils.Ptr(""), + }, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "zone id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "zone id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "repeated primary flags", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + primaryFlagValues: []string{"1.2.3.4", "5.6.7.8"}, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Primaries = utils.Ptr( + append(*model.Primaries, "1.2.3.4", "5.6.7.8"), + ) + }), + }, + { + description: "repeated primary flags with list value", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + primaryFlagValues: []string{"1.2.3.4,5.6.7.8"}, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Primaries = utils.Ptr( + append(*model.Primaries, "1.2.3.4", "5.6.7.8"), + ) + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + for _, value := range tt.primaryFlagValues { + err := cmd.Flags().Set(primaryFlag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", primaryFlag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing input: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest dns.ApiPartialUpdateZoneRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "required fields only", + model: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + ZoneId: testZoneId, + }, + expectedRequest: testClient.PartialUpdateZone(testCtx, testProjectId, testZoneId). + PartialUpdateZonePayload(dns.PartialUpdateZonePayload{}), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/dns/zone/zone.go b/internal/cmd/dns/zone/zone.go new file mode 100644 index 00000000..86503a32 --- /dev/null +++ b/internal/cmd/dns/zone/zone.go @@ -0,0 +1,33 @@ +package zone + +import ( + "stackit/internal/cmd/dns/zone/create" + "stackit/internal/cmd/dns/zone/delete" + "stackit/internal/cmd/dns/zone/describe" + "stackit/internal/cmd/dns/zone/list" + "stackit/internal/cmd/dns/zone/update" + "stackit/internal/pkg/args" + "stackit/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "zone", + Short: "Provides functionality for DNS zones", + Long: "Provides functionality for DNS zones", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd) + return cmd +} + +func addSubcommands(cmd *cobra.Command) { + cmd.AddCommand(list.NewCmd()) + cmd.AddCommand(create.NewCmd()) + cmd.AddCommand(describe.NewCmd()) + cmd.AddCommand(update.NewCmd()) + cmd.AddCommand(delete.NewCmd()) +} diff --git a/internal/cmd/mongodbflex/instance/create/create.go b/internal/cmd/mongodbflex/instance/create/create.go new file mode 100644 index 00000000..1ca36afb --- /dev/null +++ b/internal/cmd/mongodbflex/instance/create/create.go @@ -0,0 +1,269 @@ +package create + +import ( + "context" + "errors" + "fmt" + "strings" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/confirm" + cliErr "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/flags" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/projectname" + "stackit/internal/pkg/services/mongodbflex/client" + mongodbflexUtils "stackit/internal/pkg/services/mongodbflex/utils" + "stackit/internal/pkg/spinner" + "stackit/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" + "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex/wait" +) + +const ( + instanceNameFlag = "name" + aclFlag = "acl" + backupScheduleFlag = "backup-schedule" + flavorIdFlag = "flavor-id" + cpuFlag = "cpu" + ramFlag = "ram" + replicasFlag = "replicas" + storageClassFlag = "storage-class" + storageSizeFlag = "storage-size" + versionFlag = "version" + typeFlag = "type" + + defaultBackupSchedule = "0 0/6 * * *" + defaultStorageClass = "premium-perf2-mongodb" + defaultStorageSize = 10 + defaultVersion = "6.0" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + + InstanceName *string + ACL *[]string + BackupSchedule *string + FlavorId *string + CPU *int64 + RAM *int64 + StorageClass *string + StorageSize *int64 + Version *string + Type *string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Create a MongoDB Flex instance", + Long: "Create a MongoDB Flex instance.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create a MongoDB Flex instance with name "my-instance", ACL 0.0.0.0/0 (open access) and specify flavor by CPU and RAM. Other parameters are set to default values`, + `$ stackit mongodbflex instance create --name my-instance --cpu 1 --ram 4 --acl 0.0.0.0/0`), + examples.NewExample( + `Create a MongoDB Flex instance with name "my-instance", ACL 0.0.0.0/0 (open access) and specify flavor by ID. Other parameters are set to default values`, + `$ stackit mongodbflex instance create --name my-instance --flavor-id xxx --acl 0.0.0.0/0`), + examples.NewExample( + `Create a MongoDB Flex instance with name "my-instance", allow access to a specific range of IP addresses, specify flavor by CPU and RAM and set storage size to 20 GB. Other parameters are set to default values`, + `$ stackit mongodbflex instance create --name my-instance --cpu 1 --ram 4 --acl 1.2.3.0/24 --storage-size 20`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + // Service name and operation needed for error handling + service := strings.Split(cmd.Parent().Parent().Use, " ")[0] + operation := cmd.Use + model, err := parseInput(cmd, service, operation) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, cmd) + if err != nil { + projectLabel = model.ProjectId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to create a MongoDB Flex instance for project %s?", projectLabel) + err = confirm.PromptForConfirmation(cmd, prompt) + if err != nil { + return err + } + } + + // Call API + req, err := buildRequest(ctx, service, model, apiClient) + if err != nil { + return err + } + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create MongoDB Flex instance: %w", err) + } + instanceId := *resp.Id + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(cmd) + s.Start("Creating instance") + _, err = wait.CreateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for MongoDB Flex instance creation: %w", err) + } + s.Stop() + } + + operationState := "Created" + if model.Async { + operationState = "Triggered creation of" + } + cmd.Printf("%s instance for project %s. Instance ID: %s\n", operationState, projectLabel, instanceId) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + typeFlagOptions := []string{"Single", "Replica", "Sharded"} + + cmd.Flags().StringP(instanceNameFlag, "n", "", "Instance name") + cmd.Flags().Var(flags.CIDRSliceFlag(), aclFlag, "The access control list (ACL). Must contain at least one valid subnet, for instance '0.0.0.0/0' for open access (discouraged), '1.2.3.0/24 for a public IP range of an organization, '1.2.3.4/32' for a single IP range, etc.") + cmd.Flags().String(backupScheduleFlag, defaultBackupSchedule, "Backup schedule") + cmd.Flags().String(flavorIdFlag, "", "ID of the flavor") + cmd.Flags().Int64(cpuFlag, 0, "Number of CPUs") + cmd.Flags().Int64(ramFlag, 0, "Amount of RAM (in GB)") + cmd.Flags().String(storageClassFlag, defaultStorageClass, "Storage class") + cmd.Flags().Int64(storageSizeFlag, defaultStorageSize, "Storage size (in GB)") + cmd.Flags().String(versionFlag, defaultVersion, "Version") + cmd.Flags().Var(flags.EnumFlag(false, "Replica", typeFlagOptions...), typeFlag, fmt.Sprintf("Instance type, one of %q", typeFlagOptions)) + + err := flags.MarkFlagsRequired(cmd, instanceNameFlag, aclFlag) + cobra.CheckErr(err) +} + +func parseInput(cmd *cobra.Command, service, operation string) (*inputModel, error) { + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + storageSize := flags.FlagWithDefaultToInt64Value(cmd, storageSizeFlag) + + flavorId := flags.FlagToStringPointer(cmd, flavorIdFlag) + cpu := flags.FlagToInt64Pointer(cmd, cpuFlag) + ram := flags.FlagToInt64Pointer(cmd, ramFlag) + + if flavorId == nil && (cpu == nil || ram == nil) { + return nil, &cliErr.DatabaseInputFlavorError{ + Service: service, + Operation: operation, + } + } + if flavorId != nil && (cpu != nil || ram != nil) { + return nil, &cliErr.DatabaseInputFlavorError{ + Service: service, + Operation: operation, + } + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + InstanceName: flags.FlagToStringPointer(cmd, instanceNameFlag), + ACL: flags.FlagToStringSlicePointer(cmd, aclFlag), + BackupSchedule: utils.Ptr(flags.FlagWithDefaultToStringValue(cmd, backupScheduleFlag)), + FlavorId: flavorId, + CPU: cpu, + RAM: ram, + StorageClass: utils.Ptr(flags.FlagWithDefaultToStringValue(cmd, storageClassFlag)), + StorageSize: &storageSize, + Version: utils.Ptr(flags.FlagWithDefaultToStringValue(cmd, versionFlag)), + Type: utils.Ptr(flags.FlagWithDefaultToStringValue(cmd, typeFlag)), + }, nil +} + +type MongoDBFlexClient interface { + CreateInstance(ctx context.Context, projectId string) mongodbflex.ApiCreateInstanceRequest + ListFlavorsExecute(ctx context.Context, projectId string) (*mongodbflex.ListFlavorsResponse, error) + ListStoragesExecute(ctx context.Context, projectId, flavorId string) (*mongodbflex.ListStoragesResponse, error) +} + +func buildRequest(ctx context.Context, service string, model *inputModel, apiClient MongoDBFlexClient) (mongodbflex.ApiCreateInstanceRequest, error) { + req := apiClient.CreateInstance(ctx, model.ProjectId) + + var flavorId *string + var err error + + flavors, err := apiClient.ListFlavorsExecute(ctx, model.ProjectId) + if err != nil { + return req, fmt.Errorf("get MongoDB Flex flavors: %w", err) + } + + if model.FlavorId == nil { + flavorId, err = mongodbflexUtils.LoadFlavorId(service, *model.CPU, *model.RAM, flavors.Flavors) + if err != nil { + var dsaInvalidPlanError *cliErr.DSAInvalidPlanError + if !errors.As(err, &dsaInvalidPlanError) { + return req, fmt.Errorf("load flavor ID: %w", err) + } + return req, err + } + } else { + err := mongodbflexUtils.ValidateFlavorId(service, *model.FlavorId, flavors.Flavors) + if err != nil { + return req, err + } + flavorId = model.FlavorId + } + + storages, err := apiClient.ListStoragesExecute(ctx, model.ProjectId, *flavorId) + if err != nil { + return req, fmt.Errorf("get MongoDB Flex storages: %w", err) + } + err = mongodbflexUtils.ValidateStorage(service, model.StorageClass, model.StorageSize, storages, *flavorId) + if err != nil { + return req, err + } + + // The number of replicas is enforced by the API according to the instance type + var replicas int64 + if *model.Type == "Single" { + replicas = 1 + } else if *model.Type == "Replica" { + replicas = 3 + } else if *model.Type == "Sharded" { + replicas = 9 + } else { + return req, fmt.Errorf("invalid MongoDB Flex intance type: %w", err) + } + + req = req.CreateInstancePayload(mongodbflex.CreateInstancePayload{ + Name: model.InstanceName, + Acl: &mongodbflex.ACL{Items: model.ACL}, + BackupSchedule: model.BackupSchedule, + FlavorId: flavorId, + Replicas: &replicas, + Storage: &mongodbflex.Storage{ + Class: model.StorageClass, + Size: model.StorageSize, + }, + Version: model.Version, + Options: utils.Ptr(map[string]string{ + "type": *model.Type, + }), + }) + return req, nil +} diff --git a/internal/cmd/mongodbflex/instance/create/create_test.go b/internal/cmd/mongodbflex/instance/create/create_test.go new file mode 100644 index 00000000..1dc046de --- /dev/null +++ b/internal/cmd/mongodbflex/instance/create/create_test.go @@ -0,0 +1,536 @@ +package create + +import ( + "context" + "fmt" + "testing" + + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &mongodbflex.APIClient{} + +type mongoDBFlexClientMocked struct { + listFlavorsFails bool + listFlavorsResp *mongodbflex.ListFlavorsResponse + listStoragesFails bool + listStoragesResp *mongodbflex.ListStoragesResponse +} + +func (c *mongoDBFlexClientMocked) CreateInstance(ctx context.Context, projectId string) mongodbflex.ApiCreateInstanceRequest { + return testClient.CreateInstance(ctx, projectId) +} + +func (c *mongoDBFlexClientMocked) ListStoragesExecute(_ context.Context, _, _ string) (*mongodbflex.ListStoragesResponse, error) { + if c.listFlavorsFails { + return nil, fmt.Errorf("list storages failed") + } + return c.listStoragesResp, nil +} + +func (c *mongoDBFlexClientMocked) ListFlavorsExecute(_ context.Context, _ string) (*mongodbflex.ListFlavorsResponse, error) { + if c.listFlavorsFails { + return nil, fmt.Errorf("list flavors failed") + } + return c.listFlavorsResp, nil +} + +var testProjectId = uuid.NewString() +var testFlavorId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + instanceNameFlag: "example-name", + aclFlag: "0.0.0.0/0", + backupScheduleFlag: "0 0/6 * * *", + flavorIdFlag: testFlavorId, + storageClassFlag: "premium-perf2-mongodb", + storageSizeFlag: "10", + versionFlag: "6.0", + typeFlag: "Replica", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + InstanceName: utils.Ptr("example-name"), + ACL: utils.Ptr([]string{"0.0.0.0/0"}), + BackupSchedule: utils.Ptr("0 0/6 * * *"), + FlavorId: utils.Ptr(testFlavorId), + StorageClass: utils.Ptr("premium-perf2-mongodb"), + StorageSize: utils.Ptr(int64(10)), + Version: utils.Ptr("6.0"), + Type: utils.Ptr("Replica"), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *mongodbflex.ApiCreateInstanceRequest)) mongodbflex.ApiCreateInstanceRequest { + request := testClient.CreateInstance(testCtx, testProjectId) + request = request.CreateInstancePayload(fixturePayload()) + for _, mod := range mods { + mod(&request) + } + return request +} + +func fixturePayload(mods ...func(payload *mongodbflex.CreateInstancePayload)) mongodbflex.CreateInstancePayload { + payload := mongodbflex.CreateInstancePayload{ + Name: utils.Ptr("example-name"), + Acl: &mongodbflex.ACL{Items: utils.Ptr([]string{"0.0.0.0/0"})}, + BackupSchedule: utils.Ptr("0 0/6 * * *"), + FlavorId: utils.Ptr(testFlavorId), + Replicas: utils.Ptr(int64(3)), + Storage: &mongodbflex.Storage{ + Class: utils.Ptr("premium-perf2-mongodb"), + Size: utils.Ptr(int64(10)), + }, + Version: utils.Ptr("6.0"), + Options: utils.Ptr(map[string]string{ + "type": "Replica", + }), + } + for _, mod := range mods { + mod(&payload) + } + return payload +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + aclValues []string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "with defaults", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, backupScheduleFlag) + delete(flagValues, versionFlag) + delete(flagValues, typeFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "use CPU and RAM", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[cpuFlag] = "2" + flagValues[ramFlag] = "4" + delete(flagValues, flavorIdFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.FlavorId = nil + model.CPU = utils.Ptr(int64(2)) + model.RAM = utils.Ptr(int64(4)) + }), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "invalid with flavor ID, CPU and RAM", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[cpuFlag] = "2" + flagValues[ramFlag] = "4" + }), + isValid: false, + }, + { + description: "invalid with flavor ID and CPU", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[cpuFlag] = "2" + }), + isValid: false, + }, + { + description: "invalid with CPU only", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[cpuFlag] = "2" + }), + isValid: false, + }, + { + description: "repeated acl flags", + flagValues: fixtureFlagValues(), + aclValues: []string{"198.51.100.14/24", "198.51.100.14/32"}, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.ACL = utils.Ptr( + append(*model.ACL, "198.51.100.14/24", "198.51.100.14/32"), + ) + }), + }, + { + description: "repeated acl flag with list value", + flagValues: fixtureFlagValues(), + aclValues: []string{"198.51.100.14/24,198.51.100.14/32"}, + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.ACL = utils.Ptr( + append(*model.ACL, "198.51.100.14/24", "198.51.100.14/32"), + ) + }), + }, + { + description: "no acls", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, aclFlag) + }), + aclValues: []string{}, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + for _, value := range tt.aclValues { + err := cmd.Flags().Set(aclFlag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", aclFlag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd, "mongodbflex", "create") + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest mongodbflex.ApiCreateInstanceRequest + listFlavorsFails bool + listFlavorsResp *mongodbflex.ListFlavorsResponse + listStoragesFails bool + listStoragesResp *mongodbflex.ListStoragesResponse + isValid bool + }{ + { + description: "base with flavor ID", + model: fixtureInputModel(), + isValid: true, + expectedRequest: fixtureRequest(), + listFlavorsResp: &mongodbflex.ListFlavorsResponse{ + Flavors: &[]mongodbflex.HandlersInfraFlavor{ + { + Id: utils.Ptr(testFlavorId), + Cpu: utils.Ptr(int64(2)), + Memory: utils.Ptr(int64(4)), + }, + }, + }, + listStoragesResp: &mongodbflex.ListStoragesResponse{ + StorageClasses: &[]string{"premium-perf2-mongodb"}, + StorageRange: &mongodbflex.StorageRange{ + Min: utils.Ptr(int64(10)), + Max: utils.Ptr(int64(100)), + }, + }, + }, + { + description: "with CPU and RAM", + model: fixtureInputModel( + func(model *inputModel) { + model.FlavorId = nil + model.CPU = utils.Ptr(int64(2)) + model.RAM = utils.Ptr(int64(4)) + }, + ), + isValid: true, + expectedRequest: fixtureRequest(), + listFlavorsResp: &mongodbflex.ListFlavorsResponse{ + Flavors: &[]mongodbflex.HandlersInfraFlavor{ + { + Id: utils.Ptr(testFlavorId), + Cpu: utils.Ptr(int64(2)), + Memory: utils.Ptr(int64(4)), + }, + { + Id: utils.Ptr("other-flavor"), + Cpu: utils.Ptr(int64(1)), + Memory: utils.Ptr(int64(8)), + }, + }, + }, + listStoragesResp: &mongodbflex.ListStoragesResponse{ + StorageClasses: &[]string{"premium-perf2-mongodb"}, + StorageRange: &mongodbflex.StorageRange{ + Min: utils.Ptr(int64(10)), + Max: utils.Ptr(int64(100)), + }, + }, + }, + { + description: "single instance type", + model: fixtureInputModel(func(model *inputModel) { model.Type = utils.Ptr("Single") }), + isValid: true, + expectedRequest: fixtureRequest().CreateInstancePayload(fixturePayload(func(payload *mongodbflex.CreateInstancePayload) { + payload.Options = utils.Ptr(map[string]string{"type": "Single"}) + payload.Replicas = utils.Ptr(int64(1)) + })), + listFlavorsResp: &mongodbflex.ListFlavorsResponse{ + Flavors: &[]mongodbflex.HandlersInfraFlavor{ + { + Id: utils.Ptr(testFlavorId), + Cpu: utils.Ptr(int64(2)), + Memory: utils.Ptr(int64(4)), + }, + }, + }, + listStoragesResp: &mongodbflex.ListStoragesResponse{ + StorageClasses: &[]string{"premium-perf2-mongodb"}, + StorageRange: &mongodbflex.StorageRange{ + Min: utils.Ptr(int64(10)), + Max: utils.Ptr(int64(100)), + }, + }, + }, + { + description: "sharded instance type", + model: fixtureInputModel(func(model *inputModel) { model.Type = utils.Ptr("Sharded") }), + isValid: true, + expectedRequest: fixtureRequest().CreateInstancePayload(fixturePayload(func(payload *mongodbflex.CreateInstancePayload) { + payload.Options = utils.Ptr(map[string]string{"type": "Sharded"}) + payload.Replicas = utils.Ptr(int64(9)) + })), + listFlavorsResp: &mongodbflex.ListFlavorsResponse{ + Flavors: &[]mongodbflex.HandlersInfraFlavor{ + { + Id: utils.Ptr(testFlavorId), + Cpu: utils.Ptr(int64(2)), + Memory: utils.Ptr(int64(4)), + }, + }, + }, + listStoragesResp: &mongodbflex.ListStoragesResponse{ + StorageClasses: &[]string{"premium-perf2-mongodb"}, + StorageRange: &mongodbflex.StorageRange{ + Min: utils.Ptr(int64(10)), + Max: utils.Ptr(int64(100)), + }, + }, + }, + { + description: "get flavors fails", + model: fixtureInputModel( + func(model *inputModel) { + model.FlavorId = nil + model.CPU = utils.Ptr(int64(2)) + model.RAM = utils.Ptr(int64(4)) + }, + ), + listFlavorsFails: true, + isValid: false, + }, + { + description: "flavor id not found", + model: fixtureInputModel( + func(model *inputModel) { + model.FlavorId = nil + model.CPU = utils.Ptr(int64(5)) + model.RAM = utils.Ptr(int64(9)) + }, + ), + listFlavorsResp: &mongodbflex.ListFlavorsResponse{ + Flavors: &[]mongodbflex.HandlersInfraFlavor{ + { + Id: utils.Ptr(testFlavorId), + Cpu: utils.Ptr(int64(2)), + Memory: utils.Ptr(int64(4)), + }, + { + Id: utils.Ptr("other-flavor"), + Cpu: utils.Ptr(int64(1)), + Memory: utils.Ptr(int64(8)), + }, + }, + }, + isValid: false, + }, + { + description: "get storages fails", + model: fixtureInputModel( + func(model *inputModel) { + model.FlavorId = nil + model.CPU = utils.Ptr(int64(2)) + model.RAM = utils.Ptr(int64(4)) + }, + ), + listFlavorsFails: true, + isValid: false, + }, + { + description: "invalid storage class", + model: fixtureInputModel( + func(model *inputModel) { + model.StorageClass = utils.Ptr("non-existing-class") + }, + ), + listFlavorsResp: &mongodbflex.ListFlavorsResponse{ + Flavors: &[]mongodbflex.HandlersInfraFlavor{ + { + Id: utils.Ptr(testFlavorId), + Cpu: utils.Ptr(int64(2)), + Memory: utils.Ptr(int64(4)), + }, + }, + }, + listStoragesResp: &mongodbflex.ListStoragesResponse{ + StorageClasses: &[]string{"premium-perf2-mongodb"}, + StorageRange: &mongodbflex.StorageRange{ + Min: utils.Ptr(int64(10)), + Max: utils.Ptr(int64(100)), + }, + }, + isValid: false, + }, + { + description: "invalid storage size", + model: fixtureInputModel( + func(model *inputModel) { + model.StorageSize = utils.Ptr(int64(9)) + }, + ), + listFlavorsResp: &mongodbflex.ListFlavorsResponse{ + Flavors: &[]mongodbflex.HandlersInfraFlavor{ + { + Id: utils.Ptr(testFlavorId), + Cpu: utils.Ptr(int64(2)), + Memory: utils.Ptr(int64(4)), + }, + }, + }, + listStoragesResp: &mongodbflex.ListStoragesResponse{ + StorageClasses: &[]string{"premium-perf2-mongodb"}, + StorageRange: &mongodbflex.StorageRange{ + Min: utils.Ptr(int64(10)), + Max: utils.Ptr(int64(100)), + }, + }, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := &mongoDBFlexClientMocked{ + listFlavorsFails: tt.listFlavorsFails, + listFlavorsResp: tt.listFlavorsResp, + listStoragesFails: tt.listStoragesFails, + listStoragesResp: tt.listStoragesResp, + } + request, err := buildRequest(testCtx, "mongodbflex", tt.model, client) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error building request: %v", err) + } + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/mongodbflex/instance/delete/delete.go b/internal/cmd/mongodbflex/instance/delete/delete.go new file mode 100644 index 00000000..6e311979 --- /dev/null +++ b/internal/cmd/mongodbflex/instance/delete/delete.go @@ -0,0 +1,114 @@ +package delete + +import ( + "context" + "fmt" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/confirm" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/services/mongodbflex/client" + mongodbflexUtils "stackit/internal/pkg/services/mongodbflex/utils" + "stackit/internal/pkg/spinner" + "stackit/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" + "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex/wait" +) + +const ( + instanceIdArg = "INSTANCE_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", instanceIdArg), + Short: "Delete a MongoDB Flex instance", + Long: "Delete a MongoDB Flex instance", + Args: args.SingleArg(instanceIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Delete a MongoDB Flex instance with ID "xxx"`, + "$ stackit mongodbflex instance delete xxx"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + instanceLabel, err := mongodbflexUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) + if err != nil { + instanceLabel = model.InstanceId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to delete instance %s? (This cannot be undone)", instanceLabel) + err = confirm.PromptForConfirmation(cmd, prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + err = req.Execute() + if err != nil { + return fmt.Errorf("delete MongoDB Flex instance: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(cmd) + s.Start("Deleting instance") + _, err = wait.DeleteInstanceWaitHandler(ctx, apiClient, model.ProjectId, model.InstanceId).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for MongoDB Flex instance deletion: %w", err) + } + s.Stop() + } + + operationState := "Deleted" + if model.Async { + operationState = "Triggered deletion of" + } + cmd.Printf("%s instance %s\n", operationState, instanceLabel) + return nil + }, + } + return cmd +} + +func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + instanceId := inputArgs[0] + + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: instanceId, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *mongodbflex.APIClient) mongodbflex.ApiDeleteInstanceRequest { + req := apiClient.DeleteInstance(ctx, model.ProjectId, model.InstanceId) + return req +} diff --git a/internal/cmd/mongodbflex/instance/delete/delete_test.go b/internal/cmd/mongodbflex/instance/delete/delete_test.go new file mode 100644 index 00000000..09c68c8d --- /dev/null +++ b/internal/cmd/mongodbflex/instance/delete/delete_test.go @@ -0,0 +1,215 @@ +package delete + +import ( + "context" + "testing" + + "stackit/internal/pkg/globalflags" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &mongodbflex.APIClient{} +var testProjectId = uuid.NewString() +var testInstanceId = uuid.NewString() + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testInstanceId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + InstanceId: testInstanceId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *mongodbflex.ApiDeleteInstanceRequest)) mongodbflex.ApiDeleteInstanceRequest { + request := testClient.DeleteInstance(testCtx, testProjectId, testInstanceId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "instance id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "instance id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing input: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest mongodbflex.ApiDeleteInstanceRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/mongodbflex/instance/describe/describe.go b/internal/cmd/mongodbflex/instance/describe/describe.go new file mode 100644 index 00000000..cfd22d93 --- /dev/null +++ b/internal/cmd/mongodbflex/instance/describe/describe.go @@ -0,0 +1,128 @@ +package describe + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/services/mongodbflex/client" + "stackit/internal/pkg/tables" + "stackit/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" +) + +const ( + instanceIdArg = "INSTANCE_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", instanceIdArg), + Short: "Get details of a MongoDB Flex instance", + Long: "Get details of a MongoDB Flex instance", + Args: args.SingleArg(instanceIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Get details of a MongoDB Flex instance with ID "xxx"`, + "$ stackit mongodbflex instance describe xxx"), + examples.NewExample( + `Get details of a MongoDB Flex instance with ID "xxx" in a table format`, + "$ stackit mongodbflex instance describe xxx --output-format pretty"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd, args) + if err != nil { + return err + } + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("read MongoDB Flex instance: %w", err) + } + + return outputResult(cmd, model.OutputFormat, resp.Item) + }, + } + return cmd +} + +func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + instanceId := inputArgs[0] + + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: instanceId, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *mongodbflex.APIClient) mongodbflex.ApiGetInstanceRequest { + req := apiClient.GetInstance(ctx, model.ProjectId, model.InstanceId) + return req +} + +func outputResult(cmd *cobra.Command, outputFormat string, instance *mongodbflex.Instance) error { + switch outputFormat { + case globalflags.PrettyOutputFormat: + acls := *instance.Acl.Items + strings.Join(acls, ",") + + table := tables.NewTable() + table.AddRow("ID", *instance.Id) + table.AddSeparator() + table.AddRow("NAME", *instance.Name) + table.AddSeparator() + table.AddRow("STATUS", *instance.Status) + table.AddSeparator() + table.AddRow("STORAGE SIZE", *instance.Storage.Size) + table.AddSeparator() + table.AddRow("VERSION", *instance.Version) + table.AddSeparator() + table.AddRow("ACL", acls) + table.AddSeparator() + table.AddRow("FLAVOR DESCRIPTION", *instance.Flavor.Description) + table.AddSeparator() + table.AddRow("CPU", *instance.Flavor.Cpu) + table.AddSeparator() + table.AddRow("RAM", *instance.Flavor.Memory) + table.AddSeparator() + err := table.Display(cmd) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + default: + details, err := json.MarshalIndent(instance, "", " ") + if err != nil { + return fmt.Errorf("marshal MongoDB Flex instance: %w", err) + } + cmd.Println(string(details)) + + return nil + } +} diff --git a/internal/cmd/mongodbflex/instance/describe/describe_test.go b/internal/cmd/mongodbflex/instance/describe/describe_test.go new file mode 100644 index 00000000..88211366 --- /dev/null +++ b/internal/cmd/mongodbflex/instance/describe/describe_test.go @@ -0,0 +1,215 @@ +package describe + +import ( + "context" + "testing" + + "stackit/internal/pkg/globalflags" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &mongodbflex.APIClient{} +var testProjectId = uuid.NewString() +var testInstanceId = uuid.NewString() + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testInstanceId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + InstanceId: testInstanceId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *mongodbflex.ApiGetInstanceRequest)) mongodbflex.ApiGetInstanceRequest { + request := testClient.GetInstance(testCtx, testProjectId, testInstanceId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "instance id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "instance id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing input: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest mongodbflex.ApiGetInstanceRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/mongodbflex/instance/instance.go b/internal/cmd/mongodbflex/instance/instance.go new file mode 100644 index 00000000..4ace9a44 --- /dev/null +++ b/internal/cmd/mongodbflex/instance/instance.go @@ -0,0 +1,33 @@ +package instance + +import ( + "stackit/internal/cmd/mongodbflex/instance/create" + "stackit/internal/cmd/mongodbflex/instance/delete" + "stackit/internal/cmd/mongodbflex/instance/describe" + "stackit/internal/cmd/mongodbflex/instance/list" + "stackit/internal/cmd/mongodbflex/instance/update" + "stackit/internal/pkg/args" + "stackit/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "instance", + Short: "Provides functionality for MongoDB Flex instances", + Long: "Provides functionality for MongoDB Flex instances", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd) + return cmd +} + +func addSubcommands(cmd *cobra.Command) { + cmd.AddCommand(create.NewCmd()) + cmd.AddCommand(delete.NewCmd()) + cmd.AddCommand(describe.NewCmd()) + cmd.AddCommand(list.NewCmd()) + cmd.AddCommand(update.NewCmd()) +} diff --git a/internal/cmd/mongodbflex/instance/list/list.go b/internal/cmd/mongodbflex/instance/list/list.go new file mode 100644 index 00000000..0221e726 --- /dev/null +++ b/internal/cmd/mongodbflex/instance/list/list.go @@ -0,0 +1,142 @@ +package list + +import ( + "context" + "encoding/json" + "fmt" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/flags" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/projectname" + "stackit/internal/pkg/services/mongodbflex/client" + "stackit/internal/pkg/tables" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" +) + +const ( + limitFlag = "limit" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Limit *int64 +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List all MongoDB Flex instances", + Long: "List all MongoDB Flex instances", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all MongoDB Flex instances`, + "$ stackit mongodbflex instance list"), + examples.NewExample( + `List all MongoDB Flex instances in JSON format`, + "$ stackit mongodbflex instance list --output-format json"), + examples.NewExample( + `List up to 10 MongoDB Flex instances`, + "$ stackit mongodbflex instance list --limit 10"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get MongoDB Flex instances: %w", err) + } + if resp.Items == nil || len(*resp.Items) == 0 { + projectLabel, err := projectname.GetProjectName(ctx, cmd) + if err != nil { + projectLabel = model.ProjectId + } + cmd.Printf("No instances found for project %s\n", projectLabel) + return nil + } + instances := *resp.Items + + // Truncate output + if model.Limit != nil && len(instances) > int(*model.Limit) { + instances = instances[:*model.Limit] + } + + return outputResult(cmd, model.OutputFormat, instances) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") +} + +func parseInput(cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + limit := flags.FlagToInt64Pointer(cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + Limit: flags.FlagToInt64Pointer(cmd, limitFlag), + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *mongodbflex.APIClient) mongodbflex.ApiListInstancesRequest { + req := apiClient.ListInstances(ctx, model.ProjectId).Tag("") + return req +} + +func outputResult(cmd *cobra.Command, outputFormat string, instances []mongodbflex.InstanceListInstance) error { + switch outputFormat { + case globalflags.JSONOutputFormat: + details, err := json.MarshalIndent(instances, "", " ") + if err != nil { + return fmt.Errorf("marshal MongoDB Flex instance list: %w", err) + } + cmd.Println(string(details)) + + return nil + default: + table := tables.NewTable() + table.SetHeader("ID", "NAME", "STATUS") + for i := range instances { + instance := instances[i] + table.AddRow(*instance.Id, *instance.Name, *instance.Status) + } + err := table.Display(cmd) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + } +} diff --git a/internal/cmd/mongodbflex/instance/list/list_test.go b/internal/cmd/mongodbflex/instance/list/list_test.go new file mode 100644 index 00000000..0d8da4fd --- /dev/null +++ b/internal/cmd/mongodbflex/instance/list/list_test.go @@ -0,0 +1,185 @@ +package list + +import ( + "context" + "testing" + + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &mongodbflex.APIClient{} +var testProjectId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + limitFlag: "10", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + Limit: utils.Ptr(int64(10)), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *mongodbflex.ApiListInstancesRequest)) mongodbflex.ApiListInstancesRequest { + request := testClient.ListInstances(testCtx, testProjectId).Tag("") + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "limit invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "invalid" + }), + isValid: false, + }, + { + description: "limit invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := &cobra.Command{} + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + configureFlags(cmd) + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest mongodbflex.ApiListInstancesRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/mongodbflex/instance/update/update.go b/internal/cmd/mongodbflex/instance/update/update.go new file mode 100644 index 00000000..21024f5e --- /dev/null +++ b/internal/cmd/mongodbflex/instance/update/update.go @@ -0,0 +1,287 @@ +package update + +import ( + "context" + "errors" + "fmt" + "strings" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/confirm" + cliErr "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/flags" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/services/mongodbflex/client" + mongodbflexUtils "stackit/internal/pkg/services/mongodbflex/utils" + "stackit/internal/pkg/spinner" + "stackit/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" + "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex/wait" +) + +const ( + instanceIdArg = "INSTANCE_ID" + + instanceNameFlag = "name" + aclFlag = "acl" + backupScheduleFlag = "backup-schedule" + flavorIdFlag = "flavor-id" + cpuFlag = "cpu" + ramFlag = "ram" + replicasFlag = "replicas" + storageClassFlag = "storage-class" + storageSizeFlag = "storage-size" + versionFlag = "version" + typeFlag = "type" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + + InstanceId string + InstanceName *string + ACL *[]string + BackupSchedule *string + FlavorId *string + CPU *int64 + RAM *int64 + Replicas *int64 + StorageClass *string + StorageSize *int64 + Version *string + Type *string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("update %s", instanceIdArg), + Short: "Update a MongoDB Flex instance", + Long: "Update a MongoDB Flex instance.", + Example: examples.Build( + examples.NewExample( + `Update the name of a MongoDB Flex instance`, + "$ stackit mongodbflex instance update xxx --name my-new-name"), + examples.NewExample( + `Update the version of a MongoDB Flex instance`, + "$ stackit mongodbflex instance update xxx --version 6.0"), + ), + Args: args.SingleArg(instanceIdArg, utils.ValidateUUID), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + // Service name and operation needed for error handling + service := strings.Split(cmd.Parent().Parent().Use, " ")[0] + operation := cmd.Use + model, err := parseInput(cmd, args, service, operation) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + instanceLabel, err := mongodbflexUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) + if err != nil { + instanceLabel = model.InstanceId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to update instance %s?", instanceLabel) + err = confirm.PromptForConfirmation(cmd, prompt) + if err != nil { + return err + } + } + + // Call API + req, err := buildRequest(ctx, service, model, apiClient) + if err != nil { + return err + } + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("update MongoDB Flex instance: %w", err) + } + instanceId := *resp.Item.Id + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(cmd) + s.Start("Updating instance") + _, err = wait.PartialUpdateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for MongoDB Flex instance update: %w", err) + } + s.Stop() + } + + operationState := "Updated" + if model.Async { + operationState = "Triggered update of" + } + cmd.Printf("%s instance %s\n", operationState, instanceLabel) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + typeFlagOptions := []string{"Single", "Replica", "Sharded"} + + cmd.Flags().StringP(instanceNameFlag, "n", "", "Instance name") + cmd.Flags().Var(flags.CIDRSliceFlag(), aclFlag, "List of IP networks in CIDR notation which are allowed to access this instance") + cmd.Flags().String(backupScheduleFlag, "", "Backup schedule") + cmd.Flags().String(flavorIdFlag, "", "ID of the flavor") + cmd.Flags().Int64(cpuFlag, 0, "Number of CPUs") + cmd.Flags().Int64(ramFlag, 0, "Amount of RAM (in GB)") + cmd.Flags().Int64(replicasFlag, 0, "Number of replicas") + cmd.Flags().String(storageClassFlag, "", "Storage class") + cmd.Flags().Int64(storageSizeFlag, 0, "Storage size (in GB)") + cmd.Flags().String(versionFlag, "", "Version") + cmd.Flags().Var(flags.EnumFlag(false, "", typeFlagOptions...), typeFlag, fmt.Sprintf("Instance type, one of %q", typeFlagOptions)) +} + +func parseInput(cmd *cobra.Command, inputArgs []string, service, operation string) (*inputModel, error) { + instanceId := inputArgs[0] + + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + flavorId := flags.FlagToStringPointer(cmd, flavorIdFlag) + cpu := flags.FlagToInt64Pointer(cmd, cpuFlag) + ram := flags.FlagToInt64Pointer(cmd, ramFlag) + + if flavorId != nil && (cpu != nil || ram != nil) { + return nil, &cliErr.DatabaseInputFlavorError{ + Service: service, + Operation: operation, + } + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: instanceId, + InstanceName: flags.FlagToStringPointer(cmd, instanceNameFlag), + ACL: flags.FlagToStringSlicePointer(cmd, aclFlag), + BackupSchedule: flags.FlagToStringPointer(cmd, backupScheduleFlag), + FlavorId: flavorId, + CPU: cpu, + RAM: ram, + Replicas: flags.FlagToInt64Pointer(cmd, replicasFlag), + StorageClass: flags.FlagToStringPointer(cmd, storageClassFlag), + StorageSize: flags.FlagToInt64Pointer(cmd, storageSizeFlag), + Version: flags.FlagToStringPointer(cmd, versionFlag), + Type: flags.FlagToStringPointer(cmd, typeFlag), + }, nil +} + +type MongoDBFlexClient interface { + PartialUpdateInstance(ctx context.Context, projectId, instanceId string) mongodbflex.ApiPartialUpdateInstanceRequest + GetInstanceExecute(ctx context.Context, projectId, instanceId string) (*mongodbflex.GetInstanceResponse, error) + ListFlavorsExecute(ctx context.Context, projectId string) (*mongodbflex.ListFlavorsResponse, error) + ListStoragesExecute(ctx context.Context, projectId, flavorId string) (*mongodbflex.ListStoragesResponse, error) +} + +func buildRequest(ctx context.Context, service string, model *inputModel, apiClient MongoDBFlexClient) (mongodbflex.ApiPartialUpdateInstanceRequest, error) { + req := apiClient.PartialUpdateInstance(ctx, model.ProjectId, model.InstanceId) + + var flavorId *string + var err error + + flavors, err := apiClient.ListFlavorsExecute(ctx, model.ProjectId) + if err != nil { + return req, fmt.Errorf("get MongoDB Flex flavors: %w", err) + } + + if model.FlavorId == nil && (model.RAM != nil || model.CPU != nil) { + ram := model.RAM + cpu := model.CPU + if model.RAM == nil || model.CPU == nil { + currentInstance, err := apiClient.GetInstanceExecute(ctx, model.ProjectId, model.InstanceId) + if err != nil { + return req, fmt.Errorf("get MongoDB Flex instance: %w", err) + } + if model.RAM == nil { + ram = currentInstance.Item.Flavor.Memory + } + if model.CPU == nil { + cpu = currentInstance.Item.Flavor.Cpu + } + } + flavorId, err = mongodbflexUtils.LoadFlavorId(service, *cpu, *ram, flavors.Flavors) + if err != nil { + var dsaInvalidPlanError *cliErr.DSAInvalidPlanError + if !errors.As(err, &dsaInvalidPlanError) { + return req, fmt.Errorf("load flavor ID: %w", err) + } + return req, err + } + } else if model.FlavorId != nil { + err := mongodbflexUtils.ValidateFlavorId(service, *model.FlavorId, flavors.Flavors) + if err != nil { + return req, err + } + flavorId = model.FlavorId + } + + var storages *mongodbflex.ListStoragesResponse + if model.StorageClass != nil || model.StorageSize != nil { + validationFlavorId := flavorId + if validationFlavorId == nil { + currentInstance, err := apiClient.GetInstanceExecute(ctx, model.ProjectId, model.InstanceId) + if err != nil { + return req, fmt.Errorf("get MongoDB Flex instance: %w", err) + } + validationFlavorId = currentInstance.Item.Flavor.Id + } + storages, err = apiClient.ListStoragesExecute(ctx, model.ProjectId, *validationFlavorId) + if err != nil { + return req, fmt.Errorf("get MongoDB Flex storages: %w", err) + } + err = mongodbflexUtils.ValidateStorage(service, model.StorageClass, model.StorageSize, storages, *validationFlavorId) + if err != nil { + return req, err + } + } + + var payloadAcl *mongodbflex.ACL + if model.ACL != nil { + payloadAcl = &mongodbflex.ACL{Items: model.ACL} + } + + var payloadStorage *mongodbflex.Storage + if model.StorageClass != nil || model.StorageSize != nil { + payloadStorage = &mongodbflex.Storage{ + Class: model.StorageClass, + Size: model.StorageSize, + } + } + + var payloadOptions *map[string]string + if model.Type != nil { + payloadOptions = utils.Ptr(map[string]string{ + "type": *model.Type, + }) + } + + req = req.PartialUpdateInstancePayload(mongodbflex.PartialUpdateInstancePayload{ + Name: model.InstanceName, + Acl: payloadAcl, + BackupSchedule: model.BackupSchedule, + FlavorId: flavorId, + Replicas: model.Replicas, + Storage: payloadStorage, + Version: model.Version, + Options: payloadOptions, + }) + return req, nil +} diff --git a/internal/cmd/mongodbflex/instance/update/update_test.go b/internal/cmd/mongodbflex/instance/update/update_test.go new file mode 100644 index 00000000..b98d0dfe --- /dev/null +++ b/internal/cmd/mongodbflex/instance/update/update_test.go @@ -0,0 +1,579 @@ +package update + +import ( + "context" + "fmt" + "testing" + + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &mongodbflex.APIClient{} + +type mongoDBFlexClientMocked struct { + listFlavorsFails bool + listFlavorsResp *mongodbflex.ListFlavorsResponse + listStoragesFails bool + listStoragesResp *mongodbflex.ListStoragesResponse + getInstanceFails bool + getInstanceResp *mongodbflex.GetInstanceResponse +} + +func (c *mongoDBFlexClientMocked) PartialUpdateInstance(ctx context.Context, projectId, instanceId string) mongodbflex.ApiPartialUpdateInstanceRequest { + return testClient.PartialUpdateInstance(ctx, projectId, instanceId) +} + +func (c *mongoDBFlexClientMocked) GetInstanceExecute(_ context.Context, _, _ string) (*mongodbflex.GetInstanceResponse, error) { + if c.getInstanceFails { + return nil, fmt.Errorf("get instance failed") + } + return c.getInstanceResp, nil +} + +func (c *mongoDBFlexClientMocked) ListStoragesExecute(_ context.Context, _, _ string) (*mongodbflex.ListStoragesResponse, error) { + if c.listFlavorsFails { + return nil, fmt.Errorf("list storages failed") + } + return c.listStoragesResp, nil +} + +func (c *mongoDBFlexClientMocked) ListFlavorsExecute(_ context.Context, _ string) (*mongodbflex.ListFlavorsResponse, error) { + if c.listFlavorsFails { + return nil, fmt.Errorf("list flavors failed") + } + return c.listFlavorsResp, nil +} + +var testProjectId = uuid.NewString() +var testInstanceId = uuid.NewString() +var testFlavorId = uuid.NewString() + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testInstanceId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureRequiredFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureStandardFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + flavorIdFlag: testFlavorId, + instanceNameFlag: "example-name", + aclFlag: "0.0.0.0/0", + backupScheduleFlag: "0 0 * * *", + replicasFlag: "1", + storageClassFlag: "class", + storageSizeFlag: "10", + versionFlag: "5.0", + typeFlag: "Single", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureRequiredInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + InstanceId: testInstanceId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureStandardInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + InstanceId: testInstanceId, + FlavorId: utils.Ptr(testFlavorId), + InstanceName: utils.Ptr("example-name"), + ACL: utils.Ptr([]string{"0.0.0.0/0"}), + BackupSchedule: utils.Ptr("0 0 * * *"), + Replicas: utils.Ptr(int64(1)), + StorageClass: utils.Ptr("class"), + StorageSize: utils.Ptr(int64(10)), + Version: utils.Ptr("5.0"), + Type: utils.Ptr("Single"), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *mongodbflex.ApiPartialUpdateInstanceRequest)) mongodbflex.ApiPartialUpdateInstanceRequest { + request := testClient.PartialUpdateInstance(testCtx, testProjectId, testInstanceId) + request = request.PartialUpdateInstancePayload(mongodbflex.PartialUpdateInstancePayload{}) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + aclValues []string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureRequiredFlagValues(), + isValid: true, + expectedModel: fixtureRequiredInputModel(), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureRequiredFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "all values with flavor id", + argValues: fixtureArgValues(), + flagValues: fixtureStandardFlagValues(), + isValid: true, + expectedModel: fixtureStandardInputModel(), + }, + { + description: "all values with cpu and ram", + argValues: fixtureArgValues(), + flagValues: fixtureStandardFlagValues(func(flagValues map[string]string) { + delete(flagValues, flavorIdFlag) + flagValues[cpuFlag] = "2" + flagValues[ramFlag] = "4" + }), + isValid: true, + expectedModel: fixtureStandardInputModel(func(model *inputModel) { + model.FlavorId = nil + model.CPU = utils.Ptr(int64(2)) + model.RAM = utils.Ptr(int64(4)) + }), + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureRequiredFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureRequiredFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureRequiredFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "instance id invalid 1", + argValues: []string{""}, + flagValues: fixtureRequiredFlagValues(), + isValid: false, + }, + { + description: "instance id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureRequiredFlagValues(), + isValid: false, + }, + { + description: "invalid with flavor ID, CPU and RAM", + argValues: fixtureArgValues(), + flagValues: fixtureRequiredFlagValues(func(flagValues map[string]string) { + flagValues[flavorIdFlag] = testFlavorId + flagValues[cpuFlag] = "2" + flagValues[ramFlag] = "4" + }), + isValid: false, + }, + { + description: "invalid with flavor ID and CPU", + argValues: fixtureArgValues(), + flagValues: fixtureRequiredFlagValues(func(flagValues map[string]string) { + flagValues[flavorIdFlag] = testFlavorId + flagValues[cpuFlag] = "2" + }), + isValid: false, + }, + { + description: "repeated acl flags", + argValues: fixtureArgValues(), + flagValues: fixtureRequiredFlagValues(), + aclValues: []string{"198.51.100.14/24", "198.51.100.14/32"}, + isValid: true, + expectedModel: fixtureRequiredInputModel(func(model *inputModel) { + model.ACL = utils.Ptr([]string{"198.51.100.14/24", "198.51.100.14/32"}) + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + for _, value := range tt.aclValues { + err := cmd.Flags().Set(aclFlag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", aclFlag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd, tt.argValues, "mongodbflex", "update") + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest mongodbflex.ApiPartialUpdateInstanceRequest + getInstanceFails bool + getInstanceResp *mongodbflex.GetInstanceResponse + listFlavorsFails bool + listFlavorsResp *mongodbflex.ListFlavorsResponse + listStoragesFails bool + listStoragesResp *mongodbflex.ListStoragesResponse + isValid bool + }{ + { + description: "no values", + model: fixtureRequiredInputModel(), + isValid: true, + expectedRequest: fixtureRequest(), + }, + { + description: "update flavor from id", + model: fixtureRequiredInputModel(func(model *inputModel) { + model.FlavorId = utils.Ptr(testFlavorId) + }), + isValid: true, + listFlavorsResp: &mongodbflex.ListFlavorsResponse{ + Flavors: &[]mongodbflex.HandlersInfraFlavor{ + { + Id: utils.Ptr(testFlavorId), + Cpu: utils.Ptr(int64(2)), + Memory: utils.Ptr(int64(4)), + }, + }, + }, + expectedRequest: testClient.PartialUpdateInstance(testCtx, testProjectId, testInstanceId). + PartialUpdateInstancePayload(mongodbflex.PartialUpdateInstancePayload{ + FlavorId: utils.Ptr(testFlavorId), + }), + }, + { + description: "update flavor from cpu and ram", + model: fixtureRequiredInputModel(func(model *inputModel) { + model.CPU = utils.Ptr(int64(2)) + model.RAM = utils.Ptr(int64(4)) + }), + isValid: true, + listFlavorsResp: &mongodbflex.ListFlavorsResponse{ + Flavors: &[]mongodbflex.HandlersInfraFlavor{ + { + Id: utils.Ptr(testFlavorId), + Cpu: utils.Ptr(int64(2)), + Memory: utils.Ptr(int64(4)), + }, + }, + }, + expectedRequest: testClient.PartialUpdateInstance(testCtx, testProjectId, testInstanceId). + PartialUpdateInstancePayload(mongodbflex.PartialUpdateInstancePayload{ + FlavorId: utils.Ptr(testFlavorId), + }), + }, + { + description: "update storage class only", + model: fixtureRequiredInputModel(func(model *inputModel) { + model.StorageClass = utils.Ptr("class") + }), + isValid: true, + getInstanceResp: &mongodbflex.GetInstanceResponse{ + Item: &mongodbflex.Instance{ + Flavor: &mongodbflex.Flavor{ + Id: utils.Ptr(testFlavorId), + }, + }, + }, + listStoragesResp: &mongodbflex.ListStoragesResponse{ + StorageClasses: &[]string{"class"}, + StorageRange: &mongodbflex.StorageRange{ + Min: utils.Ptr(int64(10)), + Max: utils.Ptr(int64(100)), + }, + }, + expectedRequest: testClient.PartialUpdateInstance(testCtx, testProjectId, testInstanceId). + PartialUpdateInstancePayload(mongodbflex.PartialUpdateInstancePayload{ + Storage: &mongodbflex.Storage{ + Class: utils.Ptr("class"), + }, + }), + }, + { + description: "update storage class and size", + model: fixtureRequiredInputModel(func(model *inputModel) { + model.StorageClass = utils.Ptr("class") + model.StorageSize = utils.Ptr(int64(10)) + }), + isValid: true, + getInstanceResp: &mongodbflex.GetInstanceResponse{ + Item: &mongodbflex.Instance{ + Flavor: &mongodbflex.Flavor{ + Id: utils.Ptr(testFlavorId), + }, + }, + }, + listStoragesResp: &mongodbflex.ListStoragesResponse{ + StorageClasses: &[]string{"class"}, + StorageRange: &mongodbflex.StorageRange{ + Min: utils.Ptr(int64(10)), + Max: utils.Ptr(int64(100)), + }, + }, + expectedRequest: testClient.PartialUpdateInstance(testCtx, testProjectId, testInstanceId). + PartialUpdateInstancePayload(mongodbflex.PartialUpdateInstancePayload{ + Storage: &mongodbflex.Storage{ + Class: utils.Ptr("class"), + Size: utils.Ptr(int64(10)), + }, + }), + }, + { + description: "get flavors fails", + model: fixtureRequiredInputModel( + func(model *inputModel) { + model.CPU = utils.Ptr(int64(2)) + model.RAM = utils.Ptr(int64(4)) + }, + ), + listFlavorsFails: true, + isValid: false, + }, + { + description: "flavor id not found", + model: fixtureRequiredInputModel( + func(model *inputModel) { + model.CPU = utils.Ptr(int64(5)) + model.RAM = utils.Ptr(int64(9)) + }, + ), + listFlavorsResp: &mongodbflex.ListFlavorsResponse{ + Flavors: &[]mongodbflex.HandlersInfraFlavor{ + { + Id: utils.Ptr(testFlavorId), + Cpu: utils.Ptr(int64(2)), + Memory: utils.Ptr(int64(4)), + }, + { + Id: utils.Ptr("other-flavor"), + Cpu: utils.Ptr(int64(1)), + Memory: utils.Ptr(int64(8)), + }, + }, + }, + isValid: false, + }, + { + description: "get instance fails", + model: fixtureRequiredInputModel( + func(model *inputModel) { + model.StorageClass = utils.Ptr("class") + }, + ), + getInstanceFails: true, + isValid: false, + }, + { + description: "get storages fails", + model: fixtureRequiredInputModel( + func(model *inputModel) { + model.FlavorId = nil + model.CPU = utils.Ptr(int64(2)) + model.RAM = utils.Ptr(int64(4)) + }, + ), + listFlavorsFails: true, + isValid: false, + }, + { + description: "invalid storage class", + model: fixtureRequiredInputModel( + func(model *inputModel) { + model.StorageClass = utils.Ptr("non-existing-class") + }, + ), + getInstanceResp: &mongodbflex.GetInstanceResponse{ + Item: &mongodbflex.Instance{ + Flavor: &mongodbflex.Flavor{ + Id: utils.Ptr(testFlavorId), + }, + }, + }, + listStoragesResp: &mongodbflex.ListStoragesResponse{ + StorageClasses: &[]string{"class"}, + StorageRange: &mongodbflex.StorageRange{ + Min: utils.Ptr(int64(10)), + Max: utils.Ptr(int64(100)), + }, + }, + isValid: false, + }, + { + description: "invalid storage size", + model: fixtureRequiredInputModel( + func(model *inputModel) { + model.StorageSize = utils.Ptr(int64(9)) + }, + ), + getInstanceResp: &mongodbflex.GetInstanceResponse{ + Item: &mongodbflex.Instance{ + Flavor: &mongodbflex.Flavor{ + Id: utils.Ptr(testFlavorId), + }, + }, + }, + listStoragesResp: &mongodbflex.ListStoragesResponse{ + StorageClasses: &[]string{"class"}, + StorageRange: &mongodbflex.StorageRange{ + Min: utils.Ptr(int64(10)), + Max: utils.Ptr(int64(100)), + }, + }, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := &mongoDBFlexClientMocked{ + getInstanceFails: tt.getInstanceFails, + getInstanceResp: tt.getInstanceResp, + listFlavorsFails: tt.listFlavorsFails, + listFlavorsResp: tt.listFlavorsResp, + listStoragesFails: tt.listStoragesFails, + listStoragesResp: tt.listStoragesResp, + } + request, err := buildRequest(testCtx, "mongodbflex", tt.model, client) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error building request: %v", err) + } + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/mongodbflex/mongodbflex.go b/internal/cmd/mongodbflex/mongodbflex.go new file mode 100644 index 00000000..8b017414 --- /dev/null +++ b/internal/cmd/mongodbflex/mongodbflex.go @@ -0,0 +1,29 @@ +package mongodbflex + +import ( + "stackit/internal/cmd/mongodbflex/instance" + "stackit/internal/cmd/mongodbflex/options" + "stackit/internal/cmd/mongodbflex/user" + "stackit/internal/pkg/args" + "stackit/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "mongodbflex", + Short: "Provides functionality for MongoDB Flex", + Long: "Provides functionality for MongoDB Flex", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd) + return cmd +} + +func addSubcommands(cmd *cobra.Command) { + cmd.AddCommand(instance.NewCmd()) + cmd.AddCommand(user.NewCmd()) + cmd.AddCommand(options.NewCmd()) +} diff --git a/internal/cmd/mongodbflex/options/options.go b/internal/cmd/mongodbflex/options/options.go new file mode 100644 index 00000000..5c71098b --- /dev/null +++ b/internal/cmd/mongodbflex/options/options.go @@ -0,0 +1,250 @@ +package options + +import ( + "context" + "encoding/json" + "fmt" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/flags" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/pager" + "stackit/internal/pkg/services/mongodbflex/client" + "stackit/internal/pkg/tables" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" +) + +const ( + flavorsFlag = "flavors" + versionsFlag = "versions" + storagesFlag = "storages" + flavorIdFlag = "flavor-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + + Flavors bool + Versions bool + Storages bool + FlavorId *string +} + +type options struct { + Flavors *[]mongodbflex.HandlersInfraFlavor `json:"flavors,omitempty"` + Versions *[]string `json:"versions,omitempty"` + Storages *flavorStorages `json:"flavorStorages,omitempty"` +} + +type flavorStorages struct { + FlavorId string `json:"flavorId"` + Storages *mongodbflex.ListStoragesResponse `json:"storages"` +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "options", + Short: "List MongoDB Flex options", + Long: "List MongoDB Flex options (flavors, versions and storages for a given flavor)\nPass one or more flags to filter what categories are shown.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List MongoDB Flex flavors options`, + "$ stackit mongodbflex options --flavors"), + examples.NewExample( + `List MongoDB Flex available versions`, + "$ stackit mongodbflex options --versions"), + examples.NewExample( + `List MongoDB Flex storage options for a given flavor. The flavor ID can be retrieved by running "$ stackit mongodbflex options --flavors"`, + "$ stackit mongodbflex options --storages --flavor-id "), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + // Call API + err = buildAndExecuteRequest(ctx, cmd, model, apiClient) + if err != nil { + return fmt.Errorf("get MongoDB Flex options: %w", err) + } + + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Bool(flavorsFlag, false, "Lists supported flavors") + cmd.Flags().Bool(versionsFlag, false, "Lists supported versions") + cmd.Flags().Bool(storagesFlag, false, "Lists supported storages for a given flavor") + cmd.Flags().String(flavorIdFlag, "", `The flavor ID to show storages for. Only relevant when "--storages" is passed`) +} + +func parseInput(cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(cmd) + flavors := flags.FlagToBoolValue(cmd, flavorsFlag) + versions := flags.FlagToBoolValue(cmd, versionsFlag) + storages := flags.FlagToBoolValue(cmd, storagesFlag) + flavorId := flags.FlagToStringPointer(cmd, flavorIdFlag) + + if !flavors && !versions && !storages { + return nil, fmt.Errorf("%s\n\n%s", + "please specify at least one category for which to list the available options.", + "Get details on the available flags by re-running your command with the --help flag.") + } + + if storages && flavorId == nil { + return nil, fmt.Errorf("%s\n\n%s\n%s", + `please specify a flavor ID to show storages for by setting the flag "--flavor-id ".`, + "You can get the available flavor IDs by running:", + " $ stackit mongodbflex options --flavors") + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + Flavors: flavors, + Versions: versions, + Storages: storages, + FlavorId: flags.FlagToStringPointer(cmd, flavorIdFlag), + }, nil +} + +type mongoDBFlexOptionsClient interface { + ListFlavorsExecute(ctx context.Context, projectId string) (*mongodbflex.ListFlavorsResponse, error) + ListVersionsExecute(ctx context.Context, projectId string) (*mongodbflex.ListVersionsResponse, error) + ListStoragesExecute(ctx context.Context, projectId, flavorId string) (*mongodbflex.ListStoragesResponse, error) +} + +func buildAndExecuteRequest(ctx context.Context, cmd *cobra.Command, model *inputModel, apiClient mongoDBFlexOptionsClient) error { + var flavors *mongodbflex.ListFlavorsResponse + var versions *mongodbflex.ListVersionsResponse + var storages *mongodbflex.ListStoragesResponse + var err error + + if model.Flavors { + flavors, err = apiClient.ListFlavorsExecute(ctx, model.ProjectId) + if err != nil { + return fmt.Errorf("get MongoDB Flex flavors: %w", err) + } + } + if model.Versions { + versions, err = apiClient.ListVersionsExecute(ctx, model.ProjectId) + if err != nil { + return fmt.Errorf("get MongoDB Flex versions: %w", err) + } + } + if model.Storages { + storages, err = apiClient.ListStoragesExecute(ctx, model.ProjectId, *model.FlavorId) + if err != nil { + return fmt.Errorf("get MongoDB Flex storages: %w", err) + } + } + + return outputResult(cmd, model, flavors, versions, storages) +} + +func outputResult(cmd *cobra.Command, model *inputModel, flavors *mongodbflex.ListFlavorsResponse, versions *mongodbflex.ListVersionsResponse, storages *mongodbflex.ListStoragesResponse) error { + options := &options{} + if flavors != nil { + options.Flavors = flavors.Flavors + } + if versions != nil { + options.Versions = versions.Versions + } + if storages != nil && model.FlavorId != nil { + options.Storages = &flavorStorages{ + FlavorId: *model.FlavorId, + Storages: storages, + } + } + + switch model.OutputFormat { + case globalflags.JSONOutputFormat: + details, err := json.MarshalIndent(options, "", " ") + if err != nil { + return fmt.Errorf("marshal MongoDB Flex options: %w", err) + } + cmd.Println(string(details)) + return nil + default: + return outputResultAsTable(cmd, model, options) + } +} + +func outputResultAsTable(cmd *cobra.Command, model *inputModel, options *options) error { + content := "" + if model.Flavors { + content += renderFlavors(*options.Flavors) + } + if model.Versions { + content += renderVersions(*options.Versions) + } + if model.Storages { + content += renderStorages(options.Storages.Storages) + } + + err := pager.Display(cmd, content) + if err != nil { + return fmt.Errorf("display output: %w", err) + } + + return nil +} + +func renderFlavors(flavors []mongodbflex.HandlersInfraFlavor) string { + if len(flavors) == 0 { + return "" + } + + table := tables.NewTable() + table.SetHeader("ID", "CPU", "MEMORY", "DESCRIPTION", "VALID INSTANCE TYPES") + for i := range flavors { + f := flavors[i] + table.AddRow(*f.Id, *f.Cpu, *f.Memory, *f.Description, *f.Categories) + } + return table.Render() +} + +func renderVersions(versions []string) string { + if len(versions) == 0 { + return "" + } + + table := tables.NewTable() + table.SetHeader("VERSION") + for i := range versions { + v := versions[i] + table.AddRow(v) + } + return table.Render() +} + +func renderStorages(resp *mongodbflex.ListStoragesResponse) string { + if resp.StorageClasses == nil || len(*resp.StorageClasses) == 0 { + return "" + } + storageClasses := *resp.StorageClasses + + table := tables.NewTable() + table.SetHeader("MIN STORAGE", "MAX STORAGE", "STORAGE CLASS") + for i := range storageClasses { + sc := storageClasses[i] + table.AddRow(*resp.StorageRange.Min, *resp.StorageRange.Max, sc) + } + table.EnableAutoMergeOnColumns(1, 2, 3) + return table.Render() +} diff --git a/internal/cmd/mongodbflex/options/options_test.go b/internal/cmd/mongodbflex/options/options_test.go new file mode 100644 index 00000000..ddd2e58f --- /dev/null +++ b/internal/cmd/mongodbflex/options/options_test.go @@ -0,0 +1,319 @@ +package options + +import ( + "context" + "fmt" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/utils" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + +type mongoDBFlexClientMocked struct { + listFlavorsFails bool + listVersionsFails bool + listStoragesFails bool + + listFlavorsCalled bool + listVersionsCalled bool + listStoragesCalled bool +} + +func (c *mongoDBFlexClientMocked) ListFlavorsExecute(_ context.Context, _ string) (*mongodbflex.ListFlavorsResponse, error) { + c.listFlavorsCalled = true + if c.listFlavorsFails { + return nil, fmt.Errorf("list flavors failed") + } + return utils.Ptr(mongodbflex.ListFlavorsResponse{ + Flavors: utils.Ptr([]mongodbflex.HandlersInfraFlavor{}), + }), nil +} + +func (c *mongoDBFlexClientMocked) ListVersionsExecute(_ context.Context, _ string) (*mongodbflex.ListVersionsResponse, error) { + c.listVersionsCalled = true + if c.listVersionsFails { + return nil, fmt.Errorf("list versions failed") + } + return utils.Ptr(mongodbflex.ListVersionsResponse{ + Versions: utils.Ptr([]string{}), + }), nil +} + +func (c *mongoDBFlexClientMocked) ListStoragesExecute(_ context.Context, _, _ string) (*mongodbflex.ListStoragesResponse, error) { + c.listStoragesCalled = true + if c.listStoragesFails { + return nil, fmt.Errorf("list storages failed") + } + return utils.Ptr(mongodbflex.ListStoragesResponse{ + StorageClasses: utils.Ptr([]string{}), + StorageRange: &mongodbflex.StorageRange{ + Min: utils.Ptr(int64(10)), + Max: utils.Ptr(int64(100)), + }, + }), nil +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + flavorsFlag: "true", + versionsFlag: "true", + storagesFlag: "true", + flavorIdFlag: "2.4", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModelAllFalse(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{}, + Flavors: false, + Versions: false, + Storages: false, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureInputModelAllTrue(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{}, + Flavors: true, + Versions: true, + Storages: true, + FlavorId: utils.Ptr("2.4"), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "all values", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModelAllTrue(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "some values 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[storagesFlag] = "false" + delete(flagValues, flavorIdFlag) + }), + isValid: true, + expectedModel: fixtureInputModelAllFalse(func(model *inputModel) { + model.Flavors = true + model.Versions = true + }), + }, + { + description: "some values 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, flavorsFlag) + delete(flagValues, versionsFlag) + flagValues[storagesFlag] = "true" + flagValues[flavorIdFlag] = "2.4" + }), + isValid: true, + expectedModel: fixtureInputModelAllFalse(func(model *inputModel) { + model.Storages = true + model.FlavorId = utils.Ptr("2.4") + }), + }, + { + description: "storages without flavor-id", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, flavorIdFlag) + }), + isValid: false, + }, + { + description: "flavor-id without storage", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, storagesFlag) + }), + isValid: true, + expectedModel: fixtureInputModelAllTrue(func(model *inputModel) { + model.Storages = false + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildAndExecuteRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + isValid bool + listFlavorsFails bool + listVersionsFails bool + listStoragesFails bool + expectListFlavorsCalled bool + expectListVersionsCalled bool + expectListStoragesCalled bool + }{ + { + description: "all values", + model: fixtureInputModelAllTrue(), + isValid: true, + expectListFlavorsCalled: true, + expectListVersionsCalled: true, + expectListStoragesCalled: true, + }, + { + description: "no values", + model: fixtureInputModelAllFalse(), + isValid: true, + expectListFlavorsCalled: false, + expectListVersionsCalled: false, + expectListStoragesCalled: false, + }, + { + description: "only flavors", + model: fixtureInputModelAllFalse(func(model *inputModel) { model.Flavors = true }), + isValid: true, + expectListFlavorsCalled: true, + }, + { + description: "only versions", + model: fixtureInputModelAllFalse(func(model *inputModel) { model.Versions = true }), + isValid: true, + expectListVersionsCalled: true, + }, + { + description: "only storages", + model: fixtureInputModelAllFalse(func(model *inputModel) { + model.Storages = true + model.FlavorId = utils.Ptr("2.4") + }), + isValid: true, + expectListStoragesCalled: true, + }, + { + description: "list flavors fails", + model: fixtureInputModelAllTrue(), + isValid: false, + listFlavorsFails: true, + expectListFlavorsCalled: true, + expectListVersionsCalled: false, + expectListStoragesCalled: false, + }, + { + description: "list versions fails", + model: fixtureInputModelAllTrue(), + isValid: false, + listVersionsFails: true, + expectListFlavorsCalled: true, + expectListVersionsCalled: true, + expectListStoragesCalled: false, + }, + { + description: "list storages fails", + model: fixtureInputModelAllTrue(), + isValid: false, + listStoragesFails: true, + expectListFlavorsCalled: true, + expectListVersionsCalled: true, + expectListStoragesCalled: true, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + client := &mongoDBFlexClientMocked{ + listFlavorsFails: tt.listFlavorsFails, + listVersionsFails: tt.listVersionsFails, + listStoragesFails: tt.listStoragesFails, + } + + err := buildAndExecuteRequest(testCtx, cmd, tt.model, client) + if err != nil && tt.isValid { + t.Fatalf("error building and executing request: %v", err) + } + if err == nil && !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + if !tt.isValid { + return + } + + if tt.expectListFlavorsCalled != client.listFlavorsCalled { + t.Fatalf("expected listFlavorsCalled to be %v, got %v", tt.expectListFlavorsCalled, client.listFlavorsCalled) + } + if tt.expectListVersionsCalled != client.listVersionsCalled { + t.Fatalf("expected listVersionsCalled to be %v, got %v", tt.expectListVersionsCalled, client.listVersionsCalled) + } + if tt.expectListStoragesCalled != client.listStoragesCalled { + t.Fatalf("expected listStoragesCalled to be %v, got %v", tt.expectListStoragesCalled, client.listStoragesCalled) + } + }) + } +} diff --git a/internal/cmd/mongodbflex/user/create/create.go b/internal/cmd/mongodbflex/user/create/create.go new file mode 100644 index 00000000..8692a618 --- /dev/null +++ b/internal/cmd/mongodbflex/user/create/create.go @@ -0,0 +1,145 @@ +package create + +import ( + "context" + "fmt" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/confirm" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/flags" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/services/mongodbflex/client" + mongodbflexUtils "stackit/internal/pkg/services/mongodbflex/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" +) + +const ( + instanceIdFlag = "instance-id" + usernameFlag = "username" + databaseFlag = "database" + rolesFlag = "roles" +) + +var ( + rolesDefault = []string{"read"} +) + +type inputModel struct { + *globalflags.GlobalFlagModel + + InstanceId string + Username *string + Database *string + Roles *[]string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Create a MongoDB Flex user", + Long: fmt.Sprintf("%s\n%s\n%s\n%s", + "Create a MongoDB Flex user.", + "The password is only visible upon creation and cannot be retrieved later.", + "Alternatively, you can reset the password and access the new one by running:", + " $ stackit mongodbflex user reset-password --instance-id --user-id ", + ), + Example: examples.Build( + examples.NewExample( + `Create a MongoDB Flex user for instance with ID "xxx" and specify the username`, + "$ stackit mongodbflex user create --instance-id xxx --username johndoe --roles read --database default"), + examples.NewExample( + `Create a MongoDB Flex user for instance with ID "xxx" with an automatically generated username`, + "$ stackit mongodbflex user create --instance-id xxx --roles read --database default"), + ), + Args: args.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + instanceLabel, err := mongodbflexUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) + if err != nil { + instanceLabel = model.InstanceId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to create a user for instance %s?", instanceLabel) + err = confirm.PromptForConfirmation(cmd, prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create MongoDB Flex user: %w", err) + } + user := resp.Item + + cmd.Printf("Created user for instance %s. User ID: %s\n\n", instanceLabel, *user.Id) + cmd.Printf("Username: %s\n", *user.Username) + cmd.Printf("Password: %s\n", *user.Password) + cmd.Printf("Roles: %v\n", *user.Roles) + cmd.Printf("Database: %s\n", *user.Database) + cmd.Printf("Host: %s\n", *user.Host) + cmd.Printf("Port: %d\n", *user.Port) + cmd.Printf("URI: %s\n", *user.Uri) + + return nil + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + rolesOptions := []string{"read", "readWrite"} + + cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "ID of the instance") + cmd.Flags().String(usernameFlag, "", "Username of the user. If not specified, a random username will be assigned") + cmd.Flags().String(databaseFlag, "", "The database inside the MongoDB instance that the user has access to. If it does not exist, it will be created once the user writes to it") + cmd.Flags().Var(flags.EnumSliceFlag(false, rolesDefault, rolesOptions...), rolesFlag, fmt.Sprintf("Roles of the user, possible values are %q", rolesOptions)) + + err := flags.MarkFlagsRequired(cmd, instanceIdFlag, databaseFlag) + cobra.CheckErr(err) +} + +func parseInput(cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: flags.FlagToStringValue(cmd, instanceIdFlag), + Username: flags.FlagToStringPointer(cmd, usernameFlag), + Database: flags.FlagToStringPointer(cmd, databaseFlag), + Roles: flags.FlagWithDefaultToStringSlicePointer(cmd, rolesFlag), + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *mongodbflex.APIClient) mongodbflex.ApiCreateUserRequest { + req := apiClient.CreateUser(ctx, model.ProjectId, model.InstanceId) + req = req.CreateUserPayload(mongodbflex.CreateUserPayload{ + Username: model.Username, + Database: model.Database, + Roles: model.Roles, + }) + return req +} diff --git a/internal/cmd/mongodbflex/user/create/create_test.go b/internal/cmd/mongodbflex/user/create/create_test.go new file mode 100644 index 00000000..8ea49738 --- /dev/null +++ b/internal/cmd/mongodbflex/user/create/create_test.go @@ -0,0 +1,243 @@ +package create + +import ( + "context" + "testing" + + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &mongodbflex.APIClient{} +var testProjectId = uuid.NewString() +var testInstanceId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + instanceIdFlag: testInstanceId, + usernameFlag: "johndoe", + databaseFlag: "default", + rolesFlag: "read", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + InstanceId: testInstanceId, + Username: utils.Ptr("johndoe"), + Database: utils.Ptr("default"), + Roles: utils.Ptr([]string{"read"}), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *mongodbflex.ApiCreateUserRequest)) mongodbflex.ApiCreateUserRequest { + request := testClient.CreateUser(testCtx, testProjectId, testInstanceId) + request = request.CreateUserPayload(mongodbflex.CreateUserPayload{ + Username: utils.Ptr("johndoe"), + Database: utils.Ptr("default"), + Roles: utils.Ptr([]string{"read"}), + }) + + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no username specified", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, usernameFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Username = nil + }), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "instance id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, instanceIdFlag) + }), + isValid: false, + }, + { + description: "instance id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instanceIdFlag] = "" + }), + isValid: false, + }, + { + description: "database missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, databaseFlag) + }), + isValid: false, + }, + { + description: "roles missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, rolesFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Roles = &rolesDefault + }), + }, + { + description: "invalid role", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[rolesFlag] = "invalid-role" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := &cobra.Command{} + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + configureFlags(cmd) + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest mongodbflex.ApiCreateUserRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "no username specified", + model: fixtureInputModel(func(model *inputModel) { + model.Username = nil + }), + expectedRequest: fixtureRequest().CreateUserPayload(mongodbflex.CreateUserPayload{ + Database: utils.Ptr("default"), + Roles: utils.Ptr([]string{"read"}), + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/mongodbflex/user/delete/delete.go b/internal/cmd/mongodbflex/user/delete/delete.go new file mode 100644 index 00000000..2c64a5a6 --- /dev/null +++ b/internal/cmd/mongodbflex/user/delete/delete.go @@ -0,0 +1,119 @@ +package delete + +import ( + "context" + "fmt" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/confirm" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/flags" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/services/mongodbflex/client" + mongodbflexUtils "stackit/internal/pkg/services/mongodbflex/utils" + "stackit/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" +) + +const ( + userIdArg = "USER_ID" + + instanceIdFlag = "instance-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + + InstanceId string + UserId string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", userIdArg), + Short: "Delete a MongoDB Flex user", + Long: fmt.Sprintf("%s\n%s", + "Delete a MongoDB Flex user by ID. You can get the IDs of users for an instance by running:", + " $ stackit mongodbflex user list --instance-id ", + ), + Example: examples.Build( + examples.NewExample( + `Delete a MongoDB Flex user with ID "xxx" for instance with ID "yyy"`, + "$ stackit mongodbflex user delete xxx --instance-id yyy"), + ), + Args: args.SingleArg(userIdArg, utils.ValidateUUID), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + instanceLabel, err := mongodbflexUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) + if err != nil { + instanceLabel = model.InstanceId + } + + userLabel, err := mongodbflexUtils.GetUserName(ctx, apiClient, model.ProjectId, model.InstanceId, model.UserId) + if err != nil { + userLabel = model.UserId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to delete user %s of instance %s? (This cannot be undone)", userLabel, instanceLabel) + err = confirm.PromptForConfirmation(cmd, prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + err = req.Execute() + if err != nil { + return fmt.Errorf("delete MongoDB Flex user: %w", err) + } + + cmd.Printf("Deleted user %s of instance %s\n", userLabel, instanceLabel) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "Instance ID") + + err := flags.MarkFlagsRequired(cmd, instanceIdFlag) + cobra.CheckErr(err) +} + +func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + userId := inputArgs[0] + + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: flags.FlagToStringValue(cmd, instanceIdFlag), + UserId: userId, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *mongodbflex.APIClient) mongodbflex.ApiDeleteUserRequest { + req := apiClient.DeleteUser(ctx, model.ProjectId, model.InstanceId, model.UserId) + return req +} diff --git a/internal/cmd/mongodbflex/user/delete/delete_test.go b/internal/cmd/mongodbflex/user/delete/delete_test.go new file mode 100644 index 00000000..41d72c65 --- /dev/null +++ b/internal/cmd/mongodbflex/user/delete/delete_test.go @@ -0,0 +1,242 @@ +package delete + +import ( + "context" + "testing" + + "stackit/internal/pkg/globalflags" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &mongodbflex.APIClient{} +var testProjectId = uuid.NewString() +var testInstanceId = uuid.NewString() +var testUserId = uuid.NewString() + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testUserId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + instanceIdFlag: testInstanceId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + InstanceId: testInstanceId, + UserId: testUserId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *mongodbflex.ApiDeleteUserRequest)) mongodbflex.ApiDeleteUserRequest { + request := testClient.DeleteUser(testCtx, testProjectId, testInstanceId, testUserId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "instance id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, instanceIdFlag) + }), + isValid: false, + }, + { + description: "instance id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instanceIdFlag] = "" + }), + isValid: false, + }, + { + description: "instance id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instanceIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "user id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "user id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest mongodbflex.ApiDeleteUserRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/mongodbflex/user/describe/describe.go b/internal/cmd/mongodbflex/user/describe/describe.go new file mode 100644 index 00000000..4f2892f5 --- /dev/null +++ b/internal/cmd/mongodbflex/user/describe/describe.go @@ -0,0 +1,138 @@ +package describe + +import ( + "context" + "encoding/json" + "fmt" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/flags" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/services/mongodbflex/client" + "stackit/internal/pkg/tables" + "stackit/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" +) + +const ( + userIdArg = "USER_ID" + + instanceIdFlag = "instance-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + + InstanceId string + UserId string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", userIdArg), + Short: "Get details of a MongoDB Flex user", + Long: fmt.Sprintf("%s\n%s\n%s", + "Get details of a MongoDB Flex user.", + `The user password is hidden inside the "host" field and replaced with asterisks, as it is only visible upon creation. You can reset it by running:`, + " $ stackit mongodbflex user reset-password --instance-id ", + ), + Example: examples.Build( + examples.NewExample( + `Get details of a MongoDB Flex user with ID "xxx" of instance with ID "yyy"`, + "$ stackit mongodbflex user list xxx --instance-id yyy"), + examples.NewExample( + `Get details of a MongoDB Flex user with ID "xxx" of instance with ID "xxx" in table format`, + "$ stackit mongodbflex user list xxx --instance-id yyy --output-format pretty"), + ), + Args: args.SingleArg(userIdArg, utils.ValidateUUID), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get MongoDB Flex user: %w", err) + } + + return outputResult(cmd, model.OutputFormat, *resp.Item) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "ID of the instance") + + err := flags.MarkFlagsRequired(cmd, instanceIdFlag) + cobra.CheckErr(err) +} + +func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + userId := inputArgs[0] + + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: flags.FlagToStringValue(cmd, instanceIdFlag), + UserId: userId, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *mongodbflex.APIClient) mongodbflex.ApiGetUserRequest { + req := apiClient.GetUser(ctx, model.ProjectId, model.InstanceId, model.UserId) + return req +} + +func outputResult(cmd *cobra.Command, outputFormat string, user mongodbflex.InstanceResponseUser) error { + switch outputFormat { + case globalflags.PrettyOutputFormat: + table := tables.NewTable() + table.AddRow("ID", *user.Id) + table.AddSeparator() + table.AddRow("USERNAME", *user.Username) + table.AddSeparator() + table.AddRow("ROLES", *user.Roles) + table.AddSeparator() + table.AddRow("DATABASE", *user.Database) + table.AddSeparator() + table.AddRow("HOST", *user.Host) + table.AddSeparator() + table.AddRow("PORT", *user.Port) + + err := table.Display(cmd) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + default: + details, err := json.MarshalIndent(user, "", " ") + if err != nil { + return fmt.Errorf("marshal MongoDB Flex user: %w", err) + } + cmd.Println(string(details)) + + return nil + } +} diff --git a/internal/cmd/mongodbflex/user/describe/describe_test.go b/internal/cmd/mongodbflex/user/describe/describe_test.go new file mode 100644 index 00000000..71cd422b --- /dev/null +++ b/internal/cmd/mongodbflex/user/describe/describe_test.go @@ -0,0 +1,242 @@ +package describe + +import ( + "context" + "testing" + + "stackit/internal/pkg/globalflags" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &mongodbflex.APIClient{} +var testProjectId = uuid.NewString() +var testInstanceId = uuid.NewString() +var testUserId = uuid.NewString() + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testUserId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + instanceIdFlag: testInstanceId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + InstanceId: testInstanceId, + UserId: testUserId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *mongodbflex.ApiGetUserRequest)) mongodbflex.ApiGetUserRequest { + request := testClient.GetUser(testCtx, testProjectId, testInstanceId, testUserId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "instance id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, instanceIdFlag) + }), + isValid: false, + }, + { + description: "instance id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instanceIdFlag] = "" + }), + isValid: false, + }, + { + description: "instance id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instanceIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "user id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "user id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest mongodbflex.ApiGetUserRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/mongodbflex/user/list/list.go b/internal/cmd/mongodbflex/user/list/list.go new file mode 100644 index 00000000..1523ce86 --- /dev/null +++ b/internal/cmd/mongodbflex/user/list/list.go @@ -0,0 +1,150 @@ +package list + +import ( + "context" + "encoding/json" + "fmt" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/flags" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/services/mongodbflex/client" + mongodbflexUtils "stackit/internal/pkg/services/mongodbflex/utils" + "stackit/internal/pkg/tables" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" +) + +const ( + instanceIdFlag = "instance-id" + limitFlag = "limit" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + + InstanceId *string + Limit *int64 +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List all MongoDB Flex users of an instance", + Long: "List all MongoDB Flex users of an instance.", + Example: examples.Build( + examples.NewExample( + `List all MongoDB Flex users of instance with ID "xxx"`, + "$ stackit mongodbflex user list --instance-id xxx"), + examples.NewExample( + `List all MongoDB Flex users of instance with ID "xxx" in JSON format`, + "$ stackit mongodbflex user list --instance-id xxx --output-format json"), + examples.NewExample( + `List up to 10 MongoDB Flex users of instance with ID "xxx"`, + "$ stackit mongodbflex user list --instance-id xxx --limit 10"), + ), + Args: args.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get MongoDB Flex users: %w", err) + } + if resp.Items == nil || len(*resp.Items) == 0 { + instanceLabel, err := mongodbflexUtils.GetInstanceName(ctx, apiClient, model.ProjectId, *model.InstanceId) + if err != nil { + instanceLabel = *model.InstanceId + } + cmd.Printf("No users found for instance %s\n", instanceLabel) + return nil + } + users := *resp.Items + + // Truncate output + if model.Limit != nil && len(users) > int(*model.Limit) { + users = users[:*model.Limit] + } + + return outputResult(cmd, model.OutputFormat, users) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "Instance ID") + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") + + err := flags.MarkFlagsRequired(cmd, instanceIdFlag) + cobra.CheckErr(err) +} + +func parseInput(cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + limit := flags.FlagToInt64Pointer(cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: flags.FlagToStringPointer(cmd, instanceIdFlag), + Limit: flags.FlagToInt64Pointer(cmd, limitFlag), + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *mongodbflex.APIClient) mongodbflex.ApiListUsersRequest { + req := apiClient.ListUsers(ctx, model.ProjectId, *model.InstanceId) + return req +} + +func outputResult(cmd *cobra.Command, outputFormat string, users []mongodbflex.ListUser) error { + switch outputFormat { + case globalflags.JSONOutputFormat: + details, err := json.MarshalIndent(users, "", " ") + if err != nil { + return fmt.Errorf("marshal MongoDB Flex user list: %w", err) + } + cmd.Println(string(details)) + + return nil + default: + table := tables.NewTable() + table.SetHeader("ID", "USERNAME") + for i := range users { + user := users[i] + table.AddRow(*user.Id, *user.Username) + } + err := table.Display(cmd) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + } +} diff --git a/internal/cmd/mongodbflex/user/list/list_test.go b/internal/cmd/mongodbflex/user/list/list_test.go new file mode 100644 index 00000000..82f226eb --- /dev/null +++ b/internal/cmd/mongodbflex/user/list/list_test.go @@ -0,0 +1,202 @@ +package list + +import ( + "context" + "testing" + + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &mongodbflex.APIClient{} +var testProjectId = uuid.NewString() +var testInstanceId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + instanceIdFlag: testInstanceId, + limitFlag: "10", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + InstanceId: utils.Ptr(testInstanceId), + Limit: utils.Ptr(int64(10)), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *mongodbflex.ApiListUsersRequest)) mongodbflex.ApiListUsersRequest { + request := testClient.ListUsers(testCtx, testProjectId, testInstanceId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "instance id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, instanceIdFlag) + }), + isValid: false, + }, + { + description: "instance id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instanceIdFlag] = "" + }), + isValid: false, + }, + { + description: "limit invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "invalid" + }), + isValid: false, + }, + { + description: "limit invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := &cobra.Command{} + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + configureFlags(cmd) + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest mongodbflex.ApiListUsersRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/mongodbflex/user/reset-password/reset_password.go b/internal/cmd/mongodbflex/user/reset-password/reset_password.go new file mode 100644 index 00000000..4b8c515b --- /dev/null +++ b/internal/cmd/mongodbflex/user/reset-password/reset_password.go @@ -0,0 +1,120 @@ +package resetpassword + +import ( + "context" + "fmt" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/confirm" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/flags" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/services/mongodbflex/client" + mongodbflexUtils "stackit/internal/pkg/services/mongodbflex/utils" + "stackit/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" +) + +const ( + userIdArg = "USER_ID" + + instanceIdFlag = "instance-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + + InstanceId string + UserId string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("reset-password %s", userIdArg), + Short: "Reset the password of a MongoDB Flex user", + Long: "Reset the password of a MongoDB Flex user. The new password is returned in the response.", + Example: examples.Build( + examples.NewExample( + `Reset the password of a MongoDB Flex user with ID "xxx" of instance with ID "yyy"`, + "$ stackit mongodbflex user reset-password xxx --instance-id yyy"), + ), + Args: args.SingleArg(userIdArg, utils.ValidateUUID), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + instanceLabel, err := mongodbflexUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) + if err != nil { + instanceLabel = model.InstanceId + } + + userLabel, err := mongodbflexUtils.GetUserName(ctx, apiClient, model.ProjectId, model.InstanceId, model.UserId) + if err != nil { + userLabel = model.UserId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to reset the password of user %s of instance %s? (This cannot be undone)", userLabel, instanceLabel) + err = confirm.PromptForConfirmation(cmd, prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + user, err := req.Execute() + if err != nil { + return fmt.Errorf("reset MongoDB Flex user password: %w", err) + } + + cmd.Printf("Reset password for user %s of instance %s\n\n", userLabel, instanceLabel) + cmd.Printf("Username: %s\n", *user.Username) + cmd.Printf("New password: %s\n", *user.Password) + cmd.Printf("New URI: %s\n", *user.Uri) + return nil + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "ID of the instance") + + err := flags.MarkFlagsRequired(cmd, instanceIdFlag) + cobra.CheckErr(err) +} + +func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + userId := inputArgs[0] + + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: flags.FlagToStringValue(cmd, instanceIdFlag), + UserId: userId, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *mongodbflex.APIClient) mongodbflex.ApiResetUserRequest { + req := apiClient.ResetUser(ctx, model.ProjectId, model.InstanceId, model.UserId) + return req +} diff --git a/internal/cmd/mongodbflex/user/reset-password/reset_password_test.go b/internal/cmd/mongodbflex/user/reset-password/reset_password_test.go new file mode 100644 index 00000000..b5663ac2 --- /dev/null +++ b/internal/cmd/mongodbflex/user/reset-password/reset_password_test.go @@ -0,0 +1,242 @@ +package resetpassword + +import ( + "context" + "testing" + + "stackit/internal/pkg/globalflags" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &mongodbflex.APIClient{} +var testProjectId = uuid.NewString() +var testInstanceId = uuid.NewString() +var testUserId = uuid.NewString() + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testUserId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + instanceIdFlag: testInstanceId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + InstanceId: testInstanceId, + UserId: testUserId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *mongodbflex.ApiResetUserRequest)) mongodbflex.ApiResetUserRequest { + request := testClient.ResetUser(testCtx, testProjectId, testInstanceId, testUserId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "instance id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, instanceIdFlag) + }), + isValid: false, + }, + { + description: "instance id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instanceIdFlag] = "" + }), + isValid: false, + }, + { + description: "instance id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instanceIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "user id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "user id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest mongodbflex.ApiResetUserRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/mongodbflex/user/update/update.go b/internal/cmd/mongodbflex/user/update/update.go new file mode 100644 index 00000000..885323fe --- /dev/null +++ b/internal/cmd/mongodbflex/user/update/update.go @@ -0,0 +1,138 @@ +package update + +import ( + "context" + "fmt" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/confirm" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/flags" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/services/mongodbflex/client" + mongodbflexUtils "stackit/internal/pkg/services/mongodbflex/utils" + "stackit/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" +) + +const ( + userIdArg = "USER_ID" + + instanceIdFlag = "instance-id" + databaseFlag = "database" + rolesFlag = "roles" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + + InstanceId string + UserId string + Database *string + Roles *[]string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("update %s", userIdArg), + Short: "Update a MongoDB Flex user", + Long: "Update a MongoDB Flex user.", + Example: examples.Build( + examples.NewExample( + `Update the roles of a MongoDB Flex user with ID "xxx" of instance with ID "yyy"`, + "$ stackit mongodbflex user update xxx --instance-id yyy --roles read"), + ), + Args: args.SingleArg(userIdArg, utils.ValidateUUID), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + instanceLabel, err := mongodbflexUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) + if err != nil { + instanceLabel = model.InstanceId + } + + userLabel, err := mongodbflexUtils.GetUserName(ctx, apiClient, model.ProjectId, model.InstanceId, model.UserId) + if err != nil { + userLabel = model.UserId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to update user %s of instance %s?", userLabel, instanceLabel) + err = confirm.PromptForConfirmation(cmd, prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + err = req.Execute() + if err != nil { + return fmt.Errorf("update MongoDB Flex user: %w", err) + } + + cmd.Printf("Updated user %s of instance %s\n", userLabel, instanceLabel) + return nil + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + rolesOptions := []string{"read", "readWrite"} + + cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "ID of the instance") + cmd.Flags().String(databaseFlag, "", "The database inside the MongoDB instance that the user has access to. If it does not exist, it will be created once the user writes to it") + cmd.Flags().Var(flags.EnumSliceFlag(false, nil, rolesOptions...), rolesFlag, fmt.Sprintf("Roles of the user, possible values are %q", rolesOptions)) + + err := flags.MarkFlagsRequired(cmd, instanceIdFlag) + cobra.CheckErr(err) +} + +func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + userId := inputArgs[0] + + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + database := flags.FlagToStringPointer(cmd, databaseFlag) + roles := flags.FlagToStringSlicePointer(cmd, rolesFlag) + + if database == nil && roles == nil { + return nil, &errors.EmptyUpdateError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: flags.FlagToStringValue(cmd, instanceIdFlag), + UserId: userId, + Database: database, + Roles: roles, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *mongodbflex.APIClient) mongodbflex.ApiPartialUpdateUserRequest { + req := apiClient.PartialUpdateUser(ctx, model.ProjectId, model.InstanceId, model.UserId) + req = req.PartialUpdateUserPayload(mongodbflex.PartialUpdateUserPayload{ + Database: model.Database, + Roles: model.Roles, + }) + return req +} diff --git a/internal/cmd/mongodbflex/user/update/update_test.go b/internal/cmd/mongodbflex/user/update/update_test.go new file mode 100644 index 00000000..cf88c39d --- /dev/null +++ b/internal/cmd/mongodbflex/user/update/update_test.go @@ -0,0 +1,283 @@ +package update + +import ( + "context" + "testing" + + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &mongodbflex.APIClient{} +var testProjectId = uuid.NewString() +var testInstanceId = uuid.NewString() +var testUserId = uuid.NewString() + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testUserId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + instanceIdFlag: testInstanceId, + databaseFlag: "default", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + InstanceId: testInstanceId, + UserId: testUserId, + Database: utils.Ptr("default"), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *mongodbflex.ApiPartialUpdateUserRequest)) mongodbflex.ApiPartialUpdateUserRequest { + request := testClient.PartialUpdateUser(testCtx, testProjectId, testInstanceId, testUserId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "update roles", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[rolesFlag] = "read" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Roles = utils.Ptr([]string{"read"}) + }), + }, + { + description: "update database", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[databaseFlag] = "default" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Database = utils.Ptr("default") + }), + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "instance id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, instanceIdFlag) + }), + isValid: false, + }, + { + description: "instance id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instanceIdFlag] = "" + }), + isValid: false, + }, + { + description: "user id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "user id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "invalid role", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[rolesFlag] = "invalid-role" + }), + isValid: false, + }, + { + description: "empty update", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, databaseFlag) + delete(flagValues, rolesFlag) + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest mongodbflex.ApiPartialUpdateUserRequest + }{ + { + description: "update database only", + model: fixtureInputModel(func(model *inputModel) {}), + expectedRequest: fixtureRequest().PartialUpdateUserPayload(mongodbflex.PartialUpdateUserPayload{ + Database: utils.Ptr("default"), + }), + }, + { + description: "update roles only", + model: fixtureInputModel(func(model *inputModel) { + model.Database = nil + model.Roles = utils.Ptr([]string{"reader"}) + }), + expectedRequest: fixtureRequest().PartialUpdateUserPayload(mongodbflex.PartialUpdateUserPayload{ + Roles: &[]string{"reader"}, + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/mongodbflex/user/user.go b/internal/cmd/mongodbflex/user/user.go new file mode 100644 index 00000000..056728c9 --- /dev/null +++ b/internal/cmd/mongodbflex/user/user.go @@ -0,0 +1,35 @@ +package user + +import ( + "stackit/internal/cmd/mongodbflex/user/create" + "stackit/internal/cmd/mongodbflex/user/delete" + "stackit/internal/cmd/mongodbflex/user/describe" + "stackit/internal/cmd/mongodbflex/user/list" + resetpassword "stackit/internal/cmd/mongodbflex/user/reset-password" + "stackit/internal/cmd/mongodbflex/user/update" + "stackit/internal/pkg/args" + "stackit/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "user", + Short: "Provides functionality for MongoDB Flex users", + Long: "Provides functionality for MongoDB Flex users", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd) + return cmd +} + +func addSubcommands(cmd *cobra.Command) { + cmd.AddCommand(create.NewCmd()) + cmd.AddCommand(delete.NewCmd()) + cmd.AddCommand(describe.NewCmd()) + cmd.AddCommand(list.NewCmd()) + cmd.AddCommand(resetpassword.NewCmd()) + cmd.AddCommand(update.NewCmd()) +} diff --git a/internal/cmd/organization/member/add/add.go b/internal/cmd/organization/member/add/add.go new file mode 100644 index 00000000..90d5d98e --- /dev/null +++ b/internal/cmd/organization/member/add/add.go @@ -0,0 +1,122 @@ +package add + +import ( + "context" + "fmt" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/confirm" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/flags" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/services/membership/client" + "stackit/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/membership" +) + +const ( + subjectArg = "SUBJECT" + + organizationIdFlag = "organization-id" + roleFlag = "role" + + organizationResourceType = "organization" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + + OrganizationId *string + Subject string + Role *string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("add %s", subjectArg), + Short: "Add a member to an organization", + Long: fmt.Sprintf("%s\n%s\n%s\n%s\n%s", + "Add a member to an organization.", + "A member is a combination of a subject (user, service account or client) and a role.", + "The subject is usually email address for users or name in case of clients", + "For more details on the available roles, run:", + " $ stackit organization role list --organization-id ", + ), + Args: args.SingleArg(subjectArg, nil), + Example: examples.Build( + examples.NewExample( + `Add a member to an organization with the "reader" role`, + "$ stackit organization member add someone@domain.com --organization-id xxx --role reader"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd, args) + if err != nil { + return err + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to add the %s role to %s on organization with ID %s?", *model.Role, model.Subject, *model.OrganizationId) + err = confirm.PromptForConfirmation(cmd, prompt) + if err != nil { + return err + } + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + _, err = req.Execute() + if err != nil { + return fmt.Errorf("add member: %w", err) + } + + cmd.Println("Member added") + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(organizationIdFlag, "", "The organization ID") + cmd.Flags().String(roleFlag, "", "The role to add to the subject") + + err := flags.MarkFlagsRequired(cmd, organizationIdFlag, roleFlag) + cobra.CheckErr(err) +} + +func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + subject := inputArgs[0] + + globalFlags := globalflags.Parse(cmd) + + return &inputModel{ + GlobalFlagModel: globalFlags, + OrganizationId: flags.FlagToStringPointer(cmd, organizationIdFlag), + Subject: subject, + Role: flags.FlagToStringPointer(cmd, roleFlag), + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *membership.APIClient) membership.ApiAddMembersRequest { + req := apiClient.AddMembers(ctx, *model.OrganizationId) + req = req.AddMembersPayload(membership.AddMembersPayload{ + Members: utils.Ptr([]membership.Member{ + { + Subject: utils.Ptr(model.Subject), + Role: model.Role, + }, + }), + ResourceType: utils.Ptr(organizationResourceType), + }) + return req +} diff --git a/internal/cmd/organization/member/add/add_test.go b/internal/cmd/organization/member/add/add_test.go new file mode 100644 index 00000000..be1d5636 --- /dev/null +++ b/internal/cmd/organization/member/add/add_test.go @@ -0,0 +1,203 @@ +package add + +import ( + "context" + "testing" + + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/stackitcloud/stackit-sdk-go/services/membership" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &membership.APIClient{} +var testOrganizationID = "some-organization-id" +var testSubject = "someone@domain.com" +var testRole = "reader" + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testSubject, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + organizationIdFlag: testOrganizationID, + roleFlag: testRole, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{}, + OrganizationId: utils.Ptr(testOrganizationID), + Subject: testSubject, + Role: utils.Ptr(testRole), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *membership.ApiAddMembersRequest)) membership.ApiAddMembersRequest { + request := testClient.AddMembers(testCtx, testOrganizationID) + request = request.AddMembersPayload(membership.AddMembersPayload{ + Members: utils.Ptr([]membership.Member{ + { + Subject: &testSubject, + Role: &testRole, + }, + }), + ResourceType: utils.Ptr(organizationResourceType), + }) + + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no args", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no flags", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "organization id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, organizationIdFlag) + }), + isValid: false, + }, + { + description: "role missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, roleFlag) + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest membership.ApiAddMembersRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/organization/member/list/list.go b/internal/cmd/organization/member/list/list.go new file mode 100644 index 00000000..52c409cf --- /dev/null +++ b/internal/cmd/organization/member/list/list.go @@ -0,0 +1,181 @@ +package list + +import ( + "context" + "encoding/json" + "fmt" + "sort" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/flags" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/services/membership/client" + "stackit/internal/pkg/tables" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/membership" +) + +const ( + organizationIdFlag = "organization-id" + subjectFlag = "subject" + limitFlag = "limit" + sortByFlag = "sort-by" + + organizationResourceType = "organization" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + + OrganizationId *string + Subject *string + Limit *int64 + SortBy string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List members of an organization", + Long: "List members of an organization", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all members of an organization`, + "$ stackit organization role list --organization-id xxx"), + examples.NewExample( + `List all members of an organization in JSON format`, + "$ stackit organization role list --organization-id xxx --output-format json"), + examples.NewExample( + `List up to 10 members of an organization`, + "$ stackit organization role list --organization-id xxx --limit 10"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("list members: %w", err) + } + members := *resp.Members + if len(members) == 0 { + cmd.Printf("No members found for organization with ID %s\n", *model.OrganizationId) + return nil + } + + // Truncate output + if model.Limit != nil && len(members) > int(*model.Limit) { + members = members[:*model.Limit] + } + + return outputResult(cmd, model, members) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + sortByFlagOptions := []string{"subject", "role"} + + cmd.Flags().String(organizationIdFlag, "", "The organization ID") + cmd.Flags().String(subjectFlag, "", "Filter by subject (Identifier of user, service account or client. Usually email address in case of users or name in case of clients)") + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") + cmd.Flags().Var(flags.EnumFlag(false, "subject", sortByFlagOptions...), sortByFlag, fmt.Sprintf("Sort entries by a specific field, one of %q", sortByFlagOptions)) + + err := flags.MarkFlagsRequired(cmd, organizationIdFlag) + cobra.CheckErr(err) +} + +func parseInput(cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(cmd) + + limit := flags.FlagToInt64Pointer(cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + OrganizationId: flags.FlagToStringPointer(cmd, organizationIdFlag), + Subject: flags.FlagToStringPointer(cmd, subjectFlag), + Limit: flags.FlagToInt64Pointer(cmd, limitFlag), + SortBy: flags.FlagWithDefaultToStringValue(cmd, sortByFlag), + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *membership.APIClient) membership.ApiListMembersRequest { + req := apiClient.ListMembers(ctx, organizationResourceType, *model.OrganizationId) + if model.Subject != nil { + req = req.Subject(*model.Subject) + } + return req +} + +func outputResult(cmd *cobra.Command, model *inputModel, members []membership.Member) error { + sortFn := func(i, j int) bool { + switch model.SortBy { + case "subject": + return *members[i].Subject < *members[j].Subject + case "role": + return *members[i].Role < *members[j].Role + default: + return false + } + } + sort.SliceStable(members, sortFn) + + switch model.OutputFormat { + case globalflags.JSONOutputFormat: + // Show details + details, err := json.MarshalIndent(members, "", " ") + if err != nil { + return fmt.Errorf("marshal members: %w", err) + } + cmd.Println(string(details)) + + return nil + default: + table := tables.NewTable() + table.SetHeader("SUBJECT", "ROLE") + for i := range members { + m := members[i] + // If the previous item differs from the current item on the element to sort by, add a separator between the rows to help readability + if i > 0 && sortFn(i-1, i) { + table.AddSeparator() + } + table.AddRow(*m.Subject, *m.Role) + } + + if model.SortBy == "subject" { + table.EnableAutoMergeOnColumns(1) + } else if model.SortBy == "role" { + table.EnableAutoMergeOnColumns(2) + } + + err := table.Display(cmd) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + } +} diff --git a/internal/cmd/organization/member/list/list_test.go b/internal/cmd/organization/member/list/list_test.go new file mode 100644 index 00000000..3aa5419f --- /dev/null +++ b/internal/cmd/organization/member/list/list_test.go @@ -0,0 +1,204 @@ +package list + +import ( + "context" + "testing" + + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/membership" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &membership.APIClient{} +var testOrganizationID = "some-organization-id" + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + organizationIdFlag: testOrganizationID, + limitFlag: "10", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{}, + OrganizationId: utils.Ptr(testOrganizationID), + Limit: utils.Ptr(int64(10)), + SortBy: "subject", + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *membership.ApiListMembersRequest)) membership.ApiListMembersRequest { + request := testClient.ListMembers(testCtx, organizationResourceType, testOrganizationID) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "with subject", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[subjectFlag] = "someone@domain.com" + }), + isValid: true, + expectedModel: fixtureInputModel( + func(model *inputModel) { + model.Subject = utils.Ptr("someone@domain.com") + }, + ), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "organization id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, organizationIdFlag) + }), + isValid: false, + }, + { + description: "limit invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "invalid" + }), + isValid: false, + }, + { + description: "limit invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + isValid: false, + }, + { + description: "sort by role", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[sortByFlag] = "role" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.SortBy = "role" + }), + }, + { + description: "sort by invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[sortByFlag] = "invalid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := &cobra.Command{} + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + configureFlags(cmd) + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest membership.ApiListMembersRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "with subject", + model: fixtureInputModel(func(model *inputModel) { + model.Subject = utils.Ptr("someone@domain.com") + }), + expectedRequest: fixtureRequest().Subject("someone@domain.com"), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/organization/member/member.go b/internal/cmd/organization/member/member.go new file mode 100644 index 00000000..ad9590b3 --- /dev/null +++ b/internal/cmd/organization/member/member.go @@ -0,0 +1,29 @@ +package member + +import ( + "stackit/internal/cmd/organization/member/add" + "stackit/internal/cmd/organization/member/list" + "stackit/internal/cmd/organization/member/remove" + "stackit/internal/pkg/args" + "stackit/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "member", + Short: "Provides functionality regarding organization members", + Long: "Provides functionality regarding organization members", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd) + return cmd +} + +func addSubcommands(cmd *cobra.Command) { + cmd.AddCommand(add.NewCmd()) + cmd.AddCommand(list.NewCmd()) + cmd.AddCommand(remove.NewCmd()) +} diff --git a/internal/cmd/organization/member/remove/remove.go b/internal/cmd/organization/member/remove/remove.go new file mode 100644 index 00000000..e41bc06f --- /dev/null +++ b/internal/cmd/organization/member/remove/remove.go @@ -0,0 +1,132 @@ +package remove + +import ( + "context" + "fmt" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/confirm" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/flags" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/services/membership/client" + "stackit/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/membership" +) + +const ( + subjectArg = "SUBJECT" + + organizationIdFlag = "organization-id" + roleFlag = "role" + forceFlag = "force" + + organizationResourceType = "organization" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + + OrganizationId *string + Subject string + Role *string + Force bool +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("remove %s", subjectArg), + Short: "Remove a member from an organization.", + Long: fmt.Sprintf("%s\n%s\n%s", + "Remove a member from an organization.", + "A member is a combination of a subject (user, service account or client) and a role.", + "The subject is usually email address for users or name in case of clients", + ), + Args: args.SingleArg(subjectArg, nil), + Example: examples.Build( + examples.NewExample( + `Remove a member (user "someone@domain.com" with an "editor" role) from an organization`, + "$ stackit organization member remove someone@domain.com --organization-id xxx --role editor"), + examples.NewExample( + `Remove a member (user "someone@domain.com" with a "reader" role) from an organization, along with all other roles of the subject that would stop the removal of the "reader" role`, + "$ stackit organization member remove someone@domain.com --organization-id xxx --role reader --force"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to remove the %s role from %s on organization with ID %s?", *model.Role, model.Subject, *model.OrganizationId) + if model.Force { + prompt = fmt.Sprintf("%s This will also remove other roles of the subject that would stop the removal of the requested role", prompt) + } + err = confirm.PromptForConfirmation(cmd, prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + _, err = req.Execute() + if err != nil { + return fmt.Errorf("remove member: %w", err) + } + + cmd.Println("Member removed") + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(organizationIdFlag, "", "The organization ID") + cmd.Flags().String(roleFlag, "", "The role to be removed from the subject") + cmd.Flags().Bool(forceFlag, false, "When true, removes other roles of the subject that would stop the removal of the requested role") + + err := flags.MarkFlagsRequired(cmd, organizationIdFlag, roleFlag) + cobra.CheckErr(err) +} + +func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + subject := inputArgs[0] + + globalFlags := globalflags.Parse(cmd) + + return &inputModel{ + GlobalFlagModel: globalFlags, + OrganizationId: flags.FlagToStringPointer(cmd, organizationIdFlag), + Subject: subject, + Role: flags.FlagToStringPointer(cmd, roleFlag), + Force: flags.FlagToBoolValue(cmd, forceFlag), + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *membership.APIClient) membership.ApiRemoveMembersRequest { + req := apiClient.RemoveMembers(ctx, *model.OrganizationId) + payload := membership.RemoveMembersPayload{ + Members: utils.Ptr([]membership.Member{ + { + Subject: utils.Ptr(model.Subject), + Role: model.Role, + }, + }), + ResourceType: utils.Ptr(organizationResourceType), + } + payload.ForceRemove = &model.Force + req = req.RemoveMembersPayload(payload) + return req +} diff --git a/internal/cmd/organization/member/remove/remove_test.go b/internal/cmd/organization/member/remove/remove_test.go new file mode 100644 index 00000000..23d321a6 --- /dev/null +++ b/internal/cmd/organization/member/remove/remove_test.go @@ -0,0 +1,233 @@ +package remove + +import ( + "context" + "testing" + + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/stackitcloud/stackit-sdk-go/services/membership" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &membership.APIClient{} +var testOrganizationID = "some-organization-id" +var testSubject = "someone@domain.com" +var testRole = "reader" + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testSubject, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + organizationIdFlag: testOrganizationID, + roleFlag: testRole, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{}, + OrganizationId: utils.Ptr(testOrganizationID), + Subject: testSubject, + Role: utils.Ptr(testRole), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *membership.ApiRemoveMembersRequest)) membership.ApiRemoveMembersRequest { + request := testClient.RemoveMembers(testCtx, testOrganizationID) + request = request.RemoveMembersPayload(membership.RemoveMembersPayload{ + Members: utils.Ptr([]membership.Member{ + { + Subject: &testSubject, + Role: &testRole, + }, + }), + ResourceType: utils.Ptr(organizationResourceType), + ForceRemove: utils.Ptr(false), + }) + + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "with force", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[forceFlag] = "true" + }), + isValid: true, + expectedModel: fixtureInputModel( + func(model *inputModel) { + model.Force = true + }), + }, + { + description: "no args", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no flags", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "organization id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, organizationIdFlag) + }), + isValid: false, + }, + { + description: "role missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, roleFlag) + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest membership.ApiRemoveMembersRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "with force", + model: fixtureInputModel(func(model *inputModel) { + model.Force = true + }), + expectedRequest: testClient.RemoveMembers(testCtx, testOrganizationID). + RemoveMembersPayload(membership.RemoveMembersPayload{ + Members: utils.Ptr([]membership.Member{ + { + Subject: &testSubject, + Role: &testRole, + }, + }), + ResourceType: utils.Ptr(organizationResourceType), + ForceRemove: utils.Ptr(true), + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/organization/organization.go b/internal/cmd/organization/organization.go new file mode 100644 index 00000000..ee46207d --- /dev/null +++ b/internal/cmd/organization/organization.go @@ -0,0 +1,31 @@ +package organization + +import ( + "fmt" + "stackit/internal/cmd/organization/member" + "stackit/internal/cmd/organization/role" + "stackit/internal/pkg/args" + "stackit/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "organization", + Short: "Provides functionality regarding organizations", + Long: fmt.Sprintf("%s\n%s", + "Provides functionality regarding organizations.", + "An active STACKIT organization is the root element of the resource hierarchy and a prerequisite to use any STACKIT Cloud Resource / Service", + ), + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd) + return cmd +} + +func addSubcommands(cmd *cobra.Command) { + cmd.AddCommand(member.NewCmd()) + cmd.AddCommand(role.NewCmd()) +} diff --git a/internal/cmd/organization/role/list/list.go b/internal/cmd/organization/role/list/list.go new file mode 100644 index 00000000..0671f9b3 --- /dev/null +++ b/internal/cmd/organization/role/list/list.go @@ -0,0 +1,148 @@ +package list + +import ( + "context" + "encoding/json" + "fmt" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/flags" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/services/membership/client" + "stackit/internal/pkg/tables" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/membership" +) + +const ( + organizationIdFlag = "organization-id" + limitFlag = "limit" + + organizationResourceType = "organization" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + + OrganizationId *string + Limit *int64 +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List roles and permissions of an organization", + Long: "List roles and permissions of an organization", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all roles and permissions of an organization`, + "$ stackit organization role list --organization-id xxx"), + examples.NewExample( + `List all roles and permissions of an organization in JSON format`, + "$ stackit organization role list --organization-id xxx --output-format json"), + examples.NewExample( + `List up to 10 roles and permissions of an organization`, + "$ stackit organization role list --organization-id xxx --limit 10"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get organization roles: %w", err) + } + roles := *resp.Roles + if len(roles) == 0 { + cmd.Printf("No roles found for organization with ID %s\n", *model.OrganizationId) + return nil + } + + // Truncate output + if model.Limit != nil && len(roles) > int(*model.Limit) { + roles = roles[:*model.Limit] + } + + return outputRolesResult(cmd, model.OutputFormat, roles) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(organizationIdFlag, "", "Organization ID") + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") + + err := flags.MarkFlagsRequired(cmd, organizationIdFlag) + cobra.CheckErr(err) +} + +func parseInput(cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(cmd) + + limit := flags.FlagToInt64Pointer(cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + OrganizationId: flags.FlagToStringPointer(cmd, organizationIdFlag), + Limit: flags.FlagToInt64Pointer(cmd, limitFlag), + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *membership.APIClient) membership.ApiListRolesRequest { + return apiClient.ListRoles(ctx, organizationResourceType, *model.OrganizationId) +} + +func outputRolesResult(cmd *cobra.Command, outputFormat string, roles []membership.Role) error { + switch outputFormat { + case globalflags.JSONOutputFormat: + // Show details + details, err := json.MarshalIndent(roles, "", " ") + if err != nil { + return fmt.Errorf("marshal roles: %w", err) + } + cmd.Println(string(details)) + + return nil + default: + table := tables.NewTable() + table.SetHeader("ROLE NAME", "ROLE DESCRIPTION", "PERMISSION NAME", "PERMISSION DESCRIPTION") + for i := range roles { + r := roles[i] + for j := range *r.Permissions { + p := (*r.Permissions)[j] + table.AddRow(*r.Name, *r.Description, *p.Name, *p.Description) + } + table.AddSeparator() + } + table.EnableAutoMergeOnColumns(1, 2) + err := table.Display(cmd) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + } +} diff --git a/internal/cmd/organization/role/list/list_test.go b/internal/cmd/organization/role/list/list_test.go new file mode 100644 index 00000000..b6238cd0 --- /dev/null +++ b/internal/cmd/organization/role/list/list_test.go @@ -0,0 +1,167 @@ +package list + +import ( + "context" + "testing" + + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/membership" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &membership.APIClient{} +var testOrganizationID = "some-organization-id" + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + organizationIdFlag: testOrganizationID, + limitFlag: "10", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{}, + OrganizationId: utils.Ptr(testOrganizationID), + Limit: utils.Ptr(int64(10)), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *membership.ApiListRolesRequest)) membership.ApiListRolesRequest { + request := testClient.ListRoles(testCtx, organizationResourceType, testOrganizationID) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "organization id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, organizationIdFlag) + }), + isValid: false, + }, + { + description: "limit invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "invalid" + }), + isValid: false, + }, + { + description: "limit invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := &cobra.Command{} + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + configureFlags(cmd) + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest membership.ApiListRolesRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/organization/role/role.go b/internal/cmd/organization/role/role.go new file mode 100644 index 00000000..394c20c7 --- /dev/null +++ b/internal/cmd/organization/role/role.go @@ -0,0 +1,25 @@ +package role + +import ( + "stackit/internal/cmd/organization/role/list" + "stackit/internal/pkg/args" + "stackit/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "role", + Short: "Provides functionality regarding organization roles", + Long: "Provides functionality regarding organization roles", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd) + return cmd +} + +func addSubcommands(cmd *cobra.Command) { + cmd.AddCommand(list.NewCmd()) +} diff --git a/internal/cmd/project/create/create.go b/internal/cmd/project/create/create.go new file mode 100644 index 00000000..0ab9cc5d --- /dev/null +++ b/internal/cmd/project/create/create.go @@ -0,0 +1,178 @@ +package create + +import ( + "context" + "fmt" + "regexp" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/auth" + "stackit/internal/pkg/confirm" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/flags" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/services/resourcemanager/client" + "stackit/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/resourcemanager" +) + +const ( + parentIdFlag = "parent-id" + nameFlag = "name" + labelFlag = "label" + + ownerRole = "project.owner" + labelKeyRegex = `[A-ZÄÜÖa-zäüöß0-9_-]{1,64}` + labelValueRegex = `^$|[A-ZÄÜÖa-zäüöß0-9_-]{1,64}` +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ParentId *string + Name *string + Labels *map[string]string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Create STACKIT projects", + Long: "Create STACKIT projects", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create a STACKIT project`, + "$ stackit project create --parent-id xxxx --name my-project"), + examples.NewExample( + `Create a STACKIT project with a set of labels`, + "$ stackit project create --parent-id xxxx --name my-project --label key=value --label foo=bar"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to create a project under the parent with ID %s?", *model.ParentId) + err = confirm.PromptForConfirmation(cmd, prompt) + if err != nil { + return err + } + } + + // Call API + req, err := buildRequest(ctx, model, apiClient) + if err != nil { + return fmt.Errorf("build project creation request: %w", err) + } + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create project: %w", err) + } + + cmd.Printf("Created project under the parent with ID %s. Project ID: %s\n", *model.ParentId, *resp.ProjectId) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(parentIdFlag, "", "Parent resource identifier. Both container ID (user-friendly) and UUID are supported") + cmd.Flags().String(nameFlag, "", "Project name") + cmd.Flags().StringToString(labelFlag, nil, "Labels are key-value string pairs which can be attached to a project. A label can be provided with the format key=value and the flag can be used multiple times to provide a list of labels") + + err := flags.MarkFlagsRequired(cmd, parentIdFlag, nameFlag) + cobra.CheckErr(err) +} + +func parseInput(cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(cmd) + + labels := flags.FlagToStringToStringPointer(cmd, labelFlag) + if labels != nil { + labelKeyRegex := regexp.MustCompile(labelKeyRegex) + labelValueRegex := regexp.MustCompile(labelValueRegex) + for key, value := range *labels { + if !labelKeyRegex.MatchString(key) { + return nil, &errors.FlagValidationError{ + Flag: labelFlag, + Details: fmt.Sprintf("label key %s didn't match the required regex expression %s", key, labelKeyRegex), + } + } + + if !labelValueRegex.MatchString(value) { + return nil, &errors.FlagValidationError{ + Flag: labelFlag, + Details: fmt.Sprintf("label value %s for key %s didn't match the required regex expression %s", value, key, labelValueRegex), + } + } + } + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + ParentId: flags.FlagToStringPointer(cmd, parentIdFlag), + Name: flags.FlagToStringPointer(cmd, nameFlag), + Labels: labels, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *resourcemanager.APIClient) (resourcemanager.ApiCreateProjectRequest, error) { + req := apiClient.CreateProject(ctx) + + authFlow, err := auth.GetAuthFlow() + if err != nil { + return req, fmt.Errorf("get authentication flow: %w", err) + } + var email string + switch authFlow { + case auth.AUTH_FLOW_SERVICE_ACCOUNT_TOKEN: + email, err = auth.GetAuthField(auth.SERVICE_ACCOUNT_EMAIL) + if err != nil { + return req, fmt.Errorf("get email of the service account that was used to authenticate: %w", err) + } + case auth.AUTH_FLOW_SERVICE_ACCOUNT_KEY: + email, err = auth.GetAuthField(auth.SERVICE_ACCOUNT_EMAIL) + if err != nil { + return req, fmt.Errorf("get email of the service account that was used to authenticate: %w", err) + } + case auth.AUTH_FLOW_USER_TOKEN: + email, err = auth.GetAuthField(auth.USER_EMAIL) + if err != nil { + return req, fmt.Errorf("get your user email from configuration: %w", err) + } + default: + return req, fmt.Errorf("the configured authentication flow (%s) is not supported, please report this issue", authFlow) + } + + if email == "" { + return req, fmt.Errorf("the authenticated subject email cannot be empty, please report this issue") + } + + req = req.CreateProjectPayload(resourcemanager.CreateProjectPayload{ + ContainerParentId: model.ParentId, + Name: model.Name, + Labels: model.Labels, + Members: &[]resourcemanager.ProjectMember{ + { + Role: utils.Ptr(ownerRole), + Subject: utils.Ptr(email), + }, + }, + }) + + return req, nil +} diff --git a/internal/cmd/project/create/create_test.go b/internal/cmd/project/create/create_test.go new file mode 100644 index 00000000..b37a4285 --- /dev/null +++ b/internal/cmd/project/create/create_test.go @@ -0,0 +1,277 @@ +package create + +import ( + "context" + "testing" + + "stackit/internal/pkg/auth" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/resourcemanager" + "github.com/zalando/go-keyring" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &resourcemanager.APIClient{} +var testParentId = uuid.NewString() +var testEmail = "email" + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + parentIdFlag: testParentId, + nameFlag: "name", + labelFlag: "key=value", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{}, + ParentId: utils.Ptr(testParentId), + Name: utils.Ptr(nameFlag), + Labels: utils.Ptr(map[string]string{ + "key": "value", + }), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *resourcemanager.ApiCreateProjectRequest)) resourcemanager.ApiCreateProjectRequest { + request := testClient.CreateProject(testCtx) + request = request.CreateProjectPayload(resourcemanager.CreateProjectPayload{ + ContainerParentId: utils.Ptr(testParentId), + Name: utils.Ptr(nameFlag), + Labels: utils.Ptr(map[string]string{ + "key": "value", + }), + Members: &[]resourcemanager.ProjectMember{ + { + Role: utils.Ptr(ownerRole), + Subject: utils.Ptr(testEmail), + }, + }, + }) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + labelValues []string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "parent id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, parentIdFlag) + }), + isValid: false, + }, + { + description: "name missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, nameFlag) + }), + isValid: false, + }, + { + description: "multiple_labels", + flagValues: fixtureFlagValues(), + labelValues: []string{"key=value", "foo=bar"}, + expectedModel: fixtureInputModel( + func(model *inputModel) { + model.Labels = &map[string]string{ + "key": "value", + "foo": "bar", + } + }), + isValid: true, + }, + { + description: "multiple_labels_2", + flagValues: fixtureFlagValues(), + labelValues: []string{"key=value,foo=bar"}, + expectedModel: fixtureInputModel( + func(model *inputModel) { + model.Labels = &map[string]string{ + "key": "value", + "foo": "bar", + } + }), + isValid: true, + }, + { + description: "invalid_labels", + flagValues: fixtureFlagValues(), + labelValues: []string{"key"}, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + for _, value := range tt.labelValues { + err := cmd.Flags().Set(labelFlag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", labelFlag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + authFlow auth.AuthFlow + sa_email *string + user_email *string + expectedRequest resourcemanager.ApiCreateProjectRequest + isValid bool + }{ + { + description: "base_sa_key", + model: fixtureInputModel(), + authFlow: auth.AUTH_FLOW_SERVICE_ACCOUNT_KEY, + sa_email: utils.Ptr(testEmail), + expectedRequest: fixtureRequest(), + isValid: true, + }, + { + description: "base_sa_token", + model: fixtureInputModel(), + authFlow: auth.AUTH_FLOW_SERVICE_ACCOUNT_TOKEN, + sa_email: utils.Ptr(testEmail), + expectedRequest: fixtureRequest(), + isValid: true, + }, + { + description: "base_user", + model: fixtureInputModel(), + authFlow: auth.AUTH_FLOW_USER_TOKEN, + user_email: utils.Ptr(testEmail), + expectedRequest: fixtureRequest(), + isValid: true, + }, + { + description: "missing_auth_flow", + model: fixtureInputModel(), + isValid: false, + }, + { + description: "missing_email", + model: fixtureInputModel(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + keyring.MockInit() + err := auth.SetAuthFlow(tt.authFlow) + if err != nil { + t.Fatalf("Failed to set auth flow in storage: %v", err) + } + if tt.sa_email != nil { + err := auth.SetAuthField(auth.SERVICE_ACCOUNT_EMAIL, *tt.sa_email) + if err != nil { + t.Fatalf("Failed to set service account email in storage: %v", err) + } + } + if tt.user_email != nil { + err := auth.SetAuthField(auth.USER_EMAIL, *tt.user_email) + if err != nil { + t.Fatalf("Failed to set user email in storage: %v", err) + } + } + request, err := buildRequest(testCtx, tt.model, testClient) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error building request: %v", err) + } + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/project/delete/delete.go b/internal/cmd/project/delete/delete.go new file mode 100644 index 00000000..d1d78a63 --- /dev/null +++ b/internal/cmd/project/delete/delete.go @@ -0,0 +1,93 @@ +package delete + +import ( + "context" + "fmt" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/confirm" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/projectname" + "stackit/internal/pkg/services/resourcemanager/client" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/resourcemanager" +) + +type inputModel struct { + *globalflags.GlobalFlagModel +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete a STACKIT project", + Long: "Delete a STACKIT project", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Delete the configured STACKIT project`, + "$ stackit project delete"), + examples.NewExample( + `Delete a STACKIT project by explicitly providing the project ID`, + "$ stackit project delete --project-id xxx"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, cmd) + if err != nil { + projectLabel = model.ProjectId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to delete the project %s?", projectLabel) + err = confirm.PromptForConfirmation(cmd, prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + err = req.Execute() + if err != nil { + return fmt.Errorf("delete project: %w", err) + } + + cmd.Printf("Deleted project %s\n", projectLabel) + cmd.Printf("If this was your default project, consider configuring a new project ID by running:\n") + cmd.Printf(" $ stackit config set --project-id \n") + return nil + }, + } + return cmd +} + +func parseInput(cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *resourcemanager.APIClient) resourcemanager.ApiDeleteProjectRequest { + req := apiClient.DeleteProject(ctx, model.ProjectId) + return req +} diff --git a/internal/cmd/project/delete/delete_test.go b/internal/cmd/project/delete/delete_test.go new file mode 100644 index 00000000..c017bc16 --- /dev/null +++ b/internal/cmd/project/delete/delete_test.go @@ -0,0 +1,144 @@ +package delete + +import ( + "context" + "testing" + + "stackit/internal/pkg/globalflags" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/resourcemanager" +) + +type testCtxKey struct{} + +var projectIdFlag = globalflags.ProjectIdFlag +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &resourcemanager.APIClient{} +var testProjectId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *resourcemanager.ApiDeleteProjectRequest)) resourcemanager.ApiDeleteProjectRequest { + request := testClient.DeleteProject(testCtx, testProjectId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := &cobra.Command{} + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest resourcemanager.ApiDeleteProjectRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/project/describe/describe.go b/internal/cmd/project/describe/describe.go new file mode 100644 index 00000000..9eb75688 --- /dev/null +++ b/internal/cmd/project/describe/describe.go @@ -0,0 +1,123 @@ +package describe + +import ( + "context" + "encoding/json" + "fmt" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/flags" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/services/resourcemanager/client" + "stackit/internal/pkg/tables" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/resourcemanager" +) + +const ( + includeParentsFlag = "include-parents" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + IncludeParents bool +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "describe", + Short: "Get the details of a STACKIT project", + Long: "Get the details of a STACKIT project", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Get the details of the configured STACKIT project`, + "$ stackit project describe"), + examples.NewExample( + `Get the details of a STACKIT project by explicitly providing the project ID`, + "$ stackit project describe --project-id xxx"), + examples.NewExample( + `Get the details of the configured STACKIT project, including details of the parent resources`, + "$ stackit project describe --include-parents"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("read project details: %w", err) + } + + return outputResult(cmd, model.OutputFormat, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Bool(includeParentsFlag, false, "When true, the details of the parent resources will be included in the output") +} + +func parseInput(cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + IncludeParents: flags.FlagToBoolValue(cmd, includeParentsFlag), + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *resourcemanager.APIClient) resourcemanager.ApiGetProjectRequest { + req := apiClient.GetProject(ctx, model.ProjectId) + req.IncludeParents(model.IncludeParents) + return req +} + +func outputResult(cmd *cobra.Command, outputFormat string, project *resourcemanager.ProjectResponseWithParents) error { + switch outputFormat { + case globalflags.PrettyOutputFormat: + table := tables.NewTable() + table.AddRow("ID", *project.ProjectId) + table.AddSeparator() + table.AddRow("NAME", *project.Name) + table.AddSeparator() + table.AddRow("CREATION", *project.CreationTime) + table.AddSeparator() + table.AddRow("STATE", *project.LifecycleState) + table.AddSeparator() + table.AddRow("PARENT ID", *project.Parent.Id) + err := table.Display(cmd) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + default: + details, err := json.MarshalIndent(project, "", " ") + if err != nil { + return fmt.Errorf("marshal project details: %w", err) + } + cmd.Println(string(details)) + + return nil + } +} diff --git a/internal/cmd/project/describe/describe_test.go b/internal/cmd/project/describe/describe_test.go new file mode 100644 index 00000000..def5b1ce --- /dev/null +++ b/internal/cmd/project/describe/describe_test.go @@ -0,0 +1,168 @@ +package describe + +import ( + "context" + "testing" + + "stackit/internal/pkg/globalflags" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/resourcemanager" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &resourcemanager.APIClient{} +var testProjectId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + includeParentsFlag: "false", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + IncludeParents: false, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *resourcemanager.ApiGetProjectRequest)) resourcemanager.ApiGetProjectRequest { + request := testClient.GetProject(testCtx, testProjectId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + labelValues []string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest resourcemanager.ApiGetProjectRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/project/list/list.go b/internal/cmd/project/list/list.go new file mode 100644 index 00000000..405fdff6 --- /dev/null +++ b/internal/cmd/project/list/list.go @@ -0,0 +1,222 @@ +package list + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/flags" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/services/resourcemanager/client" + "stackit/internal/pkg/tables" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/resourcemanager" +) + +const ( + parentIdFlag = "parent-id" + projectIdLikeFlag = "project-id-like" + memberFlag = "member" + creationTimeAfterFlag = "creation-time-after" + limitFlag = "limit" + pageSizeFlag = "page-size" + + creationTimeAfterFormat = time.RFC3339 + pageSizeDefault = 50 +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ParentId *string + ProjectIdLike []string + Member *string + CreationTimeAfter *time.Time + Limit *int64 + PageSize int64 +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List STACKIT projects", + Long: "List all STACKIT projects that match certain criteria. At least one of parent-id, project-id-like or member flag must be provided", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all STACKIT projects that are children of a specific parent`, + "$ stackit project list --parent-id xxx"), + examples.NewExample( + `List all STACKIT projects that match the given project IDs, located under the same parent resource`, + "$ stackit project list --project-id-like xxx,yyy,zzz"), + examples.NewExample( + `List all STACKIT projects that a certain user is a member of`, + "$ stackit project list --member example@email.com"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + // Fetch projects + projects, err := fetchProjects(ctx, model, apiClient) + if err != nil { + return err + } + if len(projects) == 0 { + cmd.Print("No projects found matching the criteria\n") + return nil + } + + return outputResult(cmd, model.OutputFormat, projects) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(parentIdFlag, "", "Filter by parent identifier") + cmd.Flags().Var(flags.UUIDSliceFlag(), projectIdLikeFlag, "Filter by project identifier. Multiple project IDs can be provided, but they need to belong to the same parent resource") + cmd.Flags().String(memberFlag, "", "Filter by member. The list of projects of which the member is part of will be shown") + cmd.Flags().String(creationTimeAfterFlag, "", "Filter by creation timestamp, in a date-time with the RFC3339 layout format, e.g. 2023-01-01T00:00:00Z. The list of projects that were created after the given timestamp will be shown") + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") + cmd.Flags().Int64(pageSizeFlag, pageSizeDefault, "Number of items fetched in each API call. Does not affect the number of items in the command output") + + // At least one of parent-id, project-id-like or member flag must be provided + cmd.MarkFlagsOneRequired(parentIdFlag, projectIdLikeFlag, memberFlag) +} + +func parseInput(cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(cmd) + + creationTimeAfter, err := flags.FlagToDateTimePointer(cmd, creationTimeAfterFlag, creationTimeAfterFormat) + if err != nil { + return nil, &errors.FlagValidationError{ + Flag: creationTimeAfterFlag, + Details: err.Error(), + } + } + + limit := flags.FlagToInt64Pointer(cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + pageSize := flags.FlagWithDefaultToInt64Value(cmd, pageSizeFlag) + if pageSize < 1 { + return nil, &errors.FlagValidationError{ + Flag: pageSizeFlag, + Details: "must be greater than 0", + } + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + ParentId: flags.FlagToStringPointer(cmd, parentIdFlag), + ProjectIdLike: flags.FlagToStringSliceValue(cmd, projectIdLikeFlag), + Member: flags.FlagToStringPointer(cmd, memberFlag), + CreationTimeAfter: creationTimeAfter, + Limit: limit, + PageSize: pageSize, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient resourceManagerClient, offset int) resourcemanager.ApiListProjectsRequest { + req := apiClient.ListProjects(ctx) + if model.ParentId != nil { + req = req.ContainerParentId(*model.ParentId) + } + if model.ProjectIdLike != nil { + req = req.ContainerIds(model.ProjectIdLike) + } + if model.Member != nil { + req = req.Member(*model.Member) + } + if model.CreationTimeAfter != nil { + req = req.CreationTimeStart(*model.CreationTimeAfter) + } + req = req.Limit(float32(model.PageSize)) + req = req.Offset(float32(offset)) + return req +} + +type resourceManagerClient interface { + ListProjects(ctx context.Context) resourcemanager.ApiListProjectsRequest +} + +func fetchProjects(ctx context.Context, model *inputModel, apiClient resourceManagerClient) ([]resourcemanager.ProjectResponse, error) { + if model.Limit != nil && *model.Limit < model.PageSize { + model.PageSize = *model.Limit + } + + offset := 0 + projects := []resourcemanager.ProjectResponse{} + for { + // Call API + req := buildRequest(ctx, model, apiClient, offset) + resp, err := req.Execute() + if err != nil { + return nil, fmt.Errorf("get projects: %w", err) + } + respProjects := *resp.Items + if len(respProjects) == 0 { + break + } + projects = append(projects, respProjects...) + // Stop if no more pages + if len(respProjects) < int(model.PageSize) { + break + } + + // Stop and truncate if limit is reached + if model.Limit != nil && len(projects) >= int(*model.Limit) { + projects = projects[:*model.Limit] + break + } + offset += int(model.PageSize) + } + return projects, nil +} + +func outputResult(cmd *cobra.Command, outputFormat string, projects []resourcemanager.ProjectResponse) error { + switch outputFormat { + case globalflags.JSONOutputFormat: + details, err := json.MarshalIndent(projects, "", " ") + if err != nil { + return fmt.Errorf("marshal projects list: %w", err) + } + cmd.Println(string(details)) + + return nil + default: + table := tables.NewTable() + table.SetHeader("ID", "NAME", "STATE", "PARENT ID") + for i := range projects { + p := projects[i] + table.AddRow(*p.ProjectId, *p.Name, *p.LifecycleState, *p.Parent.Id) + } + + err := table.Display(cmd) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + } +} diff --git a/internal/cmd/project/list/list_test.go b/internal/cmd/project/list/list_test.go new file mode 100644 index 00000000..e0277498 --- /dev/null +++ b/internal/cmd/project/list/list_test.go @@ -0,0 +1,482 @@ +package list + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "testing" + "time" + + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/services/resourcemanager" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &resourcemanager.APIClient{} +var testParentId = uuid.NewString() +var testProjectIdLike = uuid.NewString() +var testCreationTimeAfter = "2023-01-01T00:00:00Z" + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + parentIdFlag: testParentId, + memberFlag: "member", + creationTimeAfterFlag: testCreationTimeAfter, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + testCreationTimeAfter, err := time.Parse(creationTimeAfterFormat, testCreationTimeAfter) + if err != nil { + return &inputModel{} + } + + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{}, + ParentId: utils.Ptr(testParentId), + Member: utils.Ptr("member"), + CreationTimeAfter: utils.Ptr(testCreationTimeAfter), + PageSize: pageSizeDefault, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *resourcemanager.ApiListProjectsRequest)) resourcemanager.ApiListProjectsRequest { + request := testClient.ListProjects(testCtx) + request = request.ContainerParentId(testParentId) + + testCreationTimeAfter, err := time.Parse(creationTimeAfterFormat, testCreationTimeAfter) + if err != nil { + return resourcemanager.ApiListProjectsRequest{} + } + request = request.CreationTimeStart(testCreationTimeAfter) + request = request.Member("member") + request = request.Limit(pageSizeDefault) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + projectIdLikevalues *[]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "parentId empty", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[parentIdFlag] = "" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.ParentId = utils.Ptr("") + }), + }, + { + description: "member empty", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[memberFlag] = "" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Member = utils.Ptr("") + }), + }, + { + description: "projectIdLike one value", + flagValues: fixtureFlagValues(), + projectIdLikevalues: utils.Ptr([]string{testProjectIdLike}), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.ProjectIdLike = []string{testProjectIdLike} + }), + }, + { + description: "projectIdLike multiple values", + flagValues: fixtureFlagValues(), + projectIdLikevalues: utils.Ptr([]string{testProjectIdLike, testProjectIdLike}), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.ProjectIdLike = []string{testProjectIdLike, testProjectIdLike} + }), + }, + { + description: "projectIdLike empty", + flagValues: fixtureFlagValues(), + projectIdLikevalues: utils.Ptr([]string{}), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.ProjectIdLike = nil + }), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "none of required fields provided", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, parentIdFlag) + delete(flagValues, memberFlag) + }), + isValid: false, + }, + { + description: "projectIdLike invalid", + flagValues: fixtureFlagValues(), + projectIdLikevalues: utils.Ptr([]string{""}), + isValid: false, + }, + { + description: "limit invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "invalid" + }), + isValid: false, + }, + { + description: "limit invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + isValid: false, + }, + { + description: "creationTimeAfter empty", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[creationTimeAfterFlag] = "" + }), + isValid: false, + }, + { + description: "creationTimeAfter invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[creationTimeAfterFlag] = "test" + }), + isValid: false, + }, + { + description: "creationTimeAfter invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[creationTimeAfterFlag] = "11:00 12/12/2023" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + if tt.projectIdLikevalues != nil { + for _, value := range *tt.projectIdLikevalues { + err := cmd.Flags().Set(projectIdLikeFlag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", projectIdLikeFlag, value, err) + } + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + err = cmd.ValidateFlagGroups() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating one of required flags: %v", err) + } + + model, err := parseInput(cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + projectIdLike []string + offset int + expectedRequest resourcemanager.ApiListProjectsRequest + }{ + { + description: "base", + model: fixtureInputModel(), + offset: 1, + expectedRequest: fixtureRequest().Offset(1), + }, + { + description: "base 2", + model: fixtureInputModel(), + offset: 10, + expectedRequest: fixtureRequest().Offset(10), + }, + { + description: "required fields only", + model: &inputModel{ + PageSize: pageSizeDefault, + }, + offset: 1, + expectedRequest: testClient.ListProjects(testCtx).Offset(1).Limit(pageSizeDefault), + }, + { + description: "projectIdLike set", + model: fixtureInputModel(), + projectIdLike: []string{testProjectIdLike}, + offset: 0, + expectedRequest: fixtureRequest().Offset(0).ContainerIds([]string{testProjectIdLike}), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + if tt.projectIdLike != nil { + tt.model.ProjectIdLike = tt.projectIdLike + } + request := buildRequest(testCtx, tt.model, testClient, tt.offset) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestFetchProjects(t *testing.T) { + tests := []struct { + description string + model *inputModel + totalItems int + apiCallFails bool + expectedNumAPICalls int + expectedNumItems int + }{ + { + description: "no limit and pageSize>totalItems", + model: fixtureInputModel(), + totalItems: 10, + expectedNumAPICalls: 1, + apiCallFails: false, + expectedNumItems: 10, + }, + { + description: "no limit and pageSizetotalItems and pageSize>totalItems", + model: fixtureInputModel(func(model *inputModel) { + model.Limit = utils.Ptr(int64(200)) + model.PageSize = 300 + }), + totalItems: 50, + expectedNumAPICalls: 1, + apiCallFails: false, + expectedNumItems: 50, + }, + { + description: "limit>totalItems and pageSize= tt.totalItems { + numItemsToReturn = 0 // Total items reached + } else if offset+limit < tt.totalItems { + numItemsToReturn = limit // Full intermediate page + } else { + numItemsToReturn = tt.totalItems - offset // Last page + } + + projects := make([]resourcemanager.ProjectResponse, numItemsToReturn) + mockedResp := resourcemanager.AllProjectsResponse{ + Items: &projects, + } + + mockedRespBytes, err := json.Marshal(mockedResp) + if err != nil { + t.Fatalf("Failed to marshal mocked response: %v", err) + } + + _, err = w.Write(mockedRespBytes) + if err != nil { + t.Errorf("Failed to write response: %v", err) + } + }) + mockedServer := httptest.NewServer(handler) + defer mockedServer.Close() + client, err := resourcemanager.NewAPIClient( + sdkConfig.WithEndpoint(mockedServer.URL), + sdkConfig.WithoutAuthentication(), + ) + if err != nil { + t.Fatalf("Failed to initialize client: %v", err) + } + + projects, err := fetchProjects(testCtx, tt.model, client) + if err != nil { + if !tt.apiCallFails { + t.Fatalf("did not fail on invalid input") + } + return + } + if err == nil && tt.apiCallFails { + t.Fatalf("did not fail on invalid input") + } + if numAPICalls != tt.expectedNumAPICalls { + t.Fatalf("Expected %d API calls, got %d", tt.expectedNumAPICalls, numAPICalls) + } + if len(projects) != tt.expectedNumItems { + t.Fatalf("Expected %d projects, got %d", tt.totalItems, len(projects)) + } + }) + } +} diff --git a/internal/cmd/project/member/add/add.go b/internal/cmd/project/member/add/add.go new file mode 100644 index 00000000..c90ef4ce --- /dev/null +++ b/internal/cmd/project/member/add/add.go @@ -0,0 +1,128 @@ +package add + +import ( + "context" + "fmt" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/confirm" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/flags" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/projectname" + "stackit/internal/pkg/services/membership/client" + "stackit/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/membership" +) + +const ( + roleFlag = "role" + + subjectArg = "SUBJECT" + + projectResourceType = "project" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + + Subject string + Role *string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("add %s", subjectArg), + Short: "Add a member to a project", + Long: fmt.Sprintf("%s\n%s\n%s\n%s\n%s", + "Add a member to a project.", + "A member is a combination of a subject (user, service account or client) and a role.", + "The subject is usually email address for users or name in case of clients", + "For more details on the available roles, run:", + " $ stackit project role list --project-id ", + ), + Args: args.SingleArg(subjectArg, nil), + Example: examples.Build( + examples.NewExample( + `Add a member to a project with the "reader" role`, + "$ stackit project member add someone@domain.com --project-id xxx --role reader"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, cmd) + if err != nil { + projectLabel = model.ProjectId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to add the role %s to %s on project %s?", *model.Role, model.Subject, projectLabel) + err = confirm.PromptForConfirmation(cmd, prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + _, err = req.Execute() + if err != nil { + return fmt.Errorf("add member: %w", err) + } + + cmd.Printf("Added the role %s to %s on project %s\n", *model.Role, model.Subject, projectLabel) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(roleFlag, "", "The role to add to the subject") + + err := flags.MarkFlagsRequired(cmd, roleFlag) + cobra.CheckErr(err) +} + +func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + subject := inputArgs[0] + + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + Subject: subject, + Role: flags.FlagToStringPointer(cmd, roleFlag), + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *membership.APIClient) membership.ApiAddMembersRequest { + req := apiClient.AddMembers(ctx, model.GlobalFlagModel.ProjectId) + req = req.AddMembersPayload(membership.AddMembersPayload{ + Members: utils.Ptr([]membership.Member{ + { + Subject: utils.Ptr(model.Subject), + Role: model.Role, + }, + }), + ResourceType: utils.Ptr(projectResourceType), + }) + return req +} diff --git a/internal/cmd/project/member/add/add_test.go b/internal/cmd/project/member/add/add_test.go new file mode 100644 index 00000000..0993f865 --- /dev/null +++ b/internal/cmd/project/member/add/add_test.go @@ -0,0 +1,201 @@ +package add + +import ( + "context" + "testing" + + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/membership" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &membership.APIClient{} +var testProjectId = uuid.NewString() +var testSubject = "someone@domain.com" +var testRole = "reader" + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testSubject, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + roleFlag: testRole, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + Subject: testSubject, + Role: utils.Ptr(testRole), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *membership.ApiAddMembersRequest)) membership.ApiAddMembersRequest { + request := testClient.AddMembers(testCtx, testProjectId) + request = request.AddMembersPayload(membership.AddMembersPayload{ + Members: utils.Ptr([]membership.Member{ + { + Subject: &testSubject, + Role: &testRole, + }, + }), + ResourceType: utils.Ptr(projectResourceType), + }) + + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no flags", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "role missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, roleFlag) + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest membership.ApiAddMembersRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/project/member/list/list.go b/internal/cmd/project/member/list/list.go new file mode 100644 index 00000000..2813b8a6 --- /dev/null +++ b/internal/cmd/project/member/list/list.go @@ -0,0 +1,182 @@ +package list + +import ( + "context" + "encoding/json" + "fmt" + "sort" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/flags" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/projectname" + "stackit/internal/pkg/services/membership/client" + "stackit/internal/pkg/tables" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/membership" +) + +const ( + subjectFlag = "subject" + limitFlag = "limit" + sortByFlag = "sort-by" + + projectResourceType = "project" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + + Subject *string + Limit *int64 + SortBy string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List members of a project", + Long: "List members of a project", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all members of a project`, + "$ stackit project role list --project-id xxx"), + examples.NewExample( + `List all members of a project, sorted by role`, + "$ stackit project role list --project-id xxx --sort-by role"), + examples.NewExample( + `List up to 10 members of a project`, + "$ stackit project role list --project-id xxx --limit 10"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("list members: %w", err) + } + members := *resp.Members + if len(members) == 0 { + projectLabel, err := projectname.GetProjectName(ctx, cmd) + if err != nil { + projectLabel = model.ProjectId + } + cmd.Printf("No members found for project %s\n", projectLabel) + return nil + } + + // Truncate output + if model.Limit != nil && len(members) > int(*model.Limit) { + members = members[:*model.Limit] + } + + return outputResult(cmd, model, members) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + sortByFlagOptions := []string{"subject", "role"} + + cmd.Flags().String(subjectFlag, "", "Filter by subject (Identifier of user, service account or client. Usually email address in case of users or name in case of clients)") + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") + cmd.Flags().Var(flags.EnumFlag(false, "subject", sortByFlagOptions...), sortByFlag, fmt.Sprintf("Sort entries by a specific field, one of %q", sortByFlagOptions)) +} + +func parseInput(cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + limit := flags.FlagToInt64Pointer(cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + Subject: flags.FlagToStringPointer(cmd, subjectFlag), + Limit: flags.FlagToInt64Pointer(cmd, limitFlag), + SortBy: flags.FlagWithDefaultToStringValue(cmd, sortByFlag), + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *membership.APIClient) membership.ApiListMembersRequest { + req := apiClient.ListMembers(ctx, projectResourceType, model.GlobalFlagModel.ProjectId) + if model.Subject != nil { + req = req.Subject(*model.Subject) + } + return req +} + +func outputResult(cmd *cobra.Command, model *inputModel, members []membership.Member) error { + sortFn := func(i, j int) bool { + switch model.SortBy { + case "subject": + return *members[i].Subject < *members[j].Subject + case "role": + return *members[i].Role < *members[j].Role + default: + return false + } + } + sort.SliceStable(members, sortFn) + + switch model.OutputFormat { + case globalflags.JSONOutputFormat: + // Show details + details, err := json.MarshalIndent(members, "", " ") + if err != nil { + return fmt.Errorf("marshal members: %w", err) + } + cmd.Println(string(details)) + + return nil + default: + table := tables.NewTable() + table.SetHeader("SUBJECT", "ROLE") + for i := range members { + m := members[i] + // If the previous item differs from the current item on the element to sort by, add a separator between the rows to help readability + if i > 0 && sortFn(i-1, i) { + table.AddSeparator() + } + table.AddRow(*m.Subject, *m.Role) + } + + if model.SortBy == "subject" { + table.EnableAutoMergeOnColumns(1) + } else if model.SortBy == "role" { + table.EnableAutoMergeOnColumns(2) + } + + err := table.Display(cmd) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + } +} diff --git a/internal/cmd/project/member/list/list_test.go b/internal/cmd/project/member/list/list_test.go new file mode 100644 index 00000000..0ac1e9e3 --- /dev/null +++ b/internal/cmd/project/member/list/list_test.go @@ -0,0 +1,208 @@ +package list + +import ( + "context" + "testing" + + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/membership" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &membership.APIClient{} +var testProjectId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + limitFlag: "10", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + Limit: utils.Ptr(int64(10)), + SortBy: "subject", + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *membership.ApiListMembersRequest)) membership.ApiListMembersRequest { + request := testClient.ListMembers(testCtx, projectResourceType, testProjectId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "with subject", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[subjectFlag] = "someone@domain.com" + }), + isValid: true, + expectedModel: fixtureInputModel( + func(model *inputModel) { + model.Subject = utils.Ptr("someone@domain.com") + }, + ), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "limit invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "invalid" + }), + isValid: false, + }, + { + description: "limit invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + isValid: false, + }, + { + description: "sort by role", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[sortByFlag] = "role" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.SortBy = "role" + }), + }, + { + description: "sort by invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[sortByFlag] = "invalid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := &cobra.Command{} + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + configureFlags(cmd) + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest membership.ApiListMembersRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "with subject", + model: fixtureInputModel(func(model *inputModel) { + model.Subject = utils.Ptr("someone@domain.com") + }), + expectedRequest: fixtureRequest().Subject("someone@domain.com"), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/project/member/member.go b/internal/cmd/project/member/member.go new file mode 100644 index 00000000..7a870f26 --- /dev/null +++ b/internal/cmd/project/member/member.go @@ -0,0 +1,29 @@ +package member + +import ( + "stackit/internal/cmd/project/member/add" + "stackit/internal/cmd/project/member/list" + "stackit/internal/cmd/project/member/remove" + "stackit/internal/pkg/args" + "stackit/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "member", + Short: "Provides functionality regarding project members", + Long: "Provides functionality regarding project members", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd) + return cmd +} + +func addSubcommands(cmd *cobra.Command) { + cmd.AddCommand(add.NewCmd()) + cmd.AddCommand(list.NewCmd()) + cmd.AddCommand(remove.NewCmd()) +} diff --git a/internal/cmd/project/member/remove/remove.go b/internal/cmd/project/member/remove/remove.go new file mode 100644 index 00000000..b2f76430 --- /dev/null +++ b/internal/cmd/project/member/remove/remove.go @@ -0,0 +1,138 @@ +package remove + +import ( + "context" + "fmt" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/confirm" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/flags" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/projectname" + "stackit/internal/pkg/services/membership/client" + "stackit/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/membership" +) + +const ( + roleFlag = "role" + forceFlag = "force" + + subjectArg = "SUBJECT" + + projectResourceType = "project" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + + Subject string + Role *string + Force bool +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("remove %s", subjectArg), + Short: "Remove a member from a project.", + Long: fmt.Sprintf("%s\n%s\n%s", + "Remove a member from a project.", + "A member is a combination of a subject (user, service account or client) and a role.", + "The subject is usually email address for users or name in case of clients", + ), + Args: args.SingleArg(subjectArg, nil), + Example: examples.Build( + examples.NewExample( + `Remove a member (user "someone@domain.com" with an "editor" role) from a project`, + "$ stackit project member remove someone@domain.com --project-id xxx --role editor"), + examples.NewExample( + `Remove a member (user "someone@domain.com" with a "reader" role) from a project, along with all other roles of the subject that would stop the removal of the "reader" role`, + "$ stackit project member remove someone@domain.com --project-id xxx --role reader --force"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, cmd) + if err != nil { + projectLabel = model.ProjectId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to remove the role %s from %s on project %s?", *model.Role, model.Subject, projectLabel) + if model.Force { + prompt = fmt.Sprintf("%s This will also remove other roles of the subject that would stop the removal of the requested role", prompt) + } + err = confirm.PromptForConfirmation(cmd, prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + _, err = req.Execute() + if err != nil { + return fmt.Errorf("remove member: %w", err) + } + + cmd.Printf("Removed the role %s from %s on project %s\n", *model.Role, model.Subject, projectLabel) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(roleFlag, "", "The role to be removed from the subject") + cmd.Flags().Bool(forceFlag, false, "When true, removes other roles of the subject that would stop the removal of the requested role") + + err := flags.MarkFlagsRequired(cmd, roleFlag) + cobra.CheckErr(err) +} + +func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + subject := inputArgs[0] + + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + Subject: subject, + Role: flags.FlagToStringPointer(cmd, roleFlag), + Force: flags.FlagToBoolValue(cmd, forceFlag), + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *membership.APIClient) membership.ApiRemoveMembersRequest { + req := apiClient.RemoveMembers(ctx, model.GlobalFlagModel.ProjectId) + payload := membership.RemoveMembersPayload{ + Members: utils.Ptr([]membership.Member{ + { + Subject: utils.Ptr(model.Subject), + Role: model.Role, + }, + }), + ResourceType: utils.Ptr(projectResourceType), + } + payload.ForceRemove = &model.Force + req = req.RemoveMembersPayload(payload) + return req +} diff --git a/internal/cmd/project/member/remove/remove_test.go b/internal/cmd/project/member/remove/remove_test.go new file mode 100644 index 00000000..40094ca5 --- /dev/null +++ b/internal/cmd/project/member/remove/remove_test.go @@ -0,0 +1,231 @@ +package remove + +import ( + "context" + "testing" + + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/membership" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &membership.APIClient{} +var testProjectId = uuid.NewString() +var testSubject = "someone@domain.com" +var testRole = "reader" + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testSubject, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + roleFlag: testRole, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + Subject: testSubject, + Role: utils.Ptr(testRole), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *membership.ApiRemoveMembersRequest)) membership.ApiRemoveMembersRequest { + request := testClient.RemoveMembers(testCtx, testProjectId) + request = request.RemoveMembersPayload(membership.RemoveMembersPayload{ + Members: utils.Ptr([]membership.Member{ + { + Subject: &testSubject, + Role: &testRole, + }, + }), + ResourceType: utils.Ptr(projectResourceType), + ForceRemove: utils.Ptr(false), + }) + + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "with force", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[forceFlag] = "true" + }), + isValid: true, + expectedModel: fixtureInputModel( + func(model *inputModel) { + model.Force = true + }), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no flags", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "role missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, roleFlag) + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest membership.ApiRemoveMembersRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "with force", + model: fixtureInputModel(func(model *inputModel) { + model.Force = true + }), + expectedRequest: testClient.RemoveMembers(testCtx, testProjectId). + RemoveMembersPayload(membership.RemoveMembersPayload{ + Members: utils.Ptr([]membership.Member{ + { + Subject: &testSubject, + Role: &testRole, + }, + }), + ResourceType: utils.Ptr(projectResourceType), + ForceRemove: utils.Ptr(true), + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/project/project.go b/internal/cmd/project/project.go new file mode 100644 index 00000000..f7fe6856 --- /dev/null +++ b/internal/cmd/project/project.go @@ -0,0 +1,41 @@ +package project + +import ( + "fmt" + "stackit/internal/cmd/project/create" + "stackit/internal/cmd/project/delete" + "stackit/internal/cmd/project/describe" + "stackit/internal/cmd/project/list" + "stackit/internal/cmd/project/member" + "stackit/internal/cmd/project/role" + "stackit/internal/cmd/project/update" + "stackit/internal/pkg/args" + "stackit/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "project", + Short: "Provides functionality regarding projects", + Long: fmt.Sprintf("%s\n%s", + "Provides functionality regarding projects.", + "A project is a container for resources which is the service that you can purchase from STACKIT.", + ), + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd) + return cmd +} + +func addSubcommands(cmd *cobra.Command) { + cmd.AddCommand(create.NewCmd()) + cmd.AddCommand(update.NewCmd()) + cmd.AddCommand(delete.NewCmd()) + cmd.AddCommand(describe.NewCmd()) + cmd.AddCommand(list.NewCmd()) + cmd.AddCommand(member.NewCmd()) + cmd.AddCommand(role.NewCmd()) +} diff --git a/internal/cmd/project/role/list/list.go b/internal/cmd/project/role/list/list.go new file mode 100644 index 00000000..1381d1b0 --- /dev/null +++ b/internal/cmd/project/role/list/list.go @@ -0,0 +1,149 @@ +package list + +import ( + "context" + "encoding/json" + "fmt" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/flags" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/projectname" + "stackit/internal/pkg/services/membership/client" + "stackit/internal/pkg/tables" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/membership" +) + +const ( + limitFlag = "limit" + + projectResourceType = "project" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + + Limit *int64 +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List roles and permissions of a project", + Long: "List roles and permissions of a project", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all roles and permissions of a project`, + "$ stackit project role list --project-id xxx"), + examples.NewExample( + `List all roles and permissions of a project in JSON format`, + "$ stackit project role list --project-id xxx --output-format json"), + examples.NewExample( + `List up to 10 roles and permissions of a project`, + "$ stackit project role list --project-id xxx --limit 10"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get project roles: %w", err) + } + roles := *resp.Roles + if len(roles) == 0 { + projectLabel, err := projectname.GetProjectName(ctx, cmd) + if err != nil { + projectLabel = model.ProjectId + } + cmd.Printf("No roles found for project %s\n", projectLabel) + return nil + } + + // Truncate output + if model.Limit != nil && len(roles) > int(*model.Limit) { + roles = roles[:*model.Limit] + } + + return outputRolesResult(cmd, model.OutputFormat, roles) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") +} + +func parseInput(cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + limit := flags.FlagToInt64Pointer(cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + Limit: flags.FlagToInt64Pointer(cmd, limitFlag), + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *membership.APIClient) membership.ApiListRolesRequest { + return apiClient.ListRoles(ctx, projectResourceType, model.GlobalFlagModel.ProjectId) +} + +func outputRolesResult(cmd *cobra.Command, outputFormat string, roles []membership.Role) error { + switch outputFormat { + case globalflags.JSONOutputFormat: + // Show details + details, err := json.MarshalIndent(roles, "", " ") + if err != nil { + return fmt.Errorf("marshal roles: %w", err) + } + cmd.Println(string(details)) + + return nil + default: + table := tables.NewTable() + table.SetHeader("ROLE NAME", "ROLE DESCRIPTION", "PERMISSION NAME", "PERMISSION DESCRIPTION") + for i := range roles { + r := roles[i] + for j := range *r.Permissions { + p := (*r.Permissions)[j] + table.AddRow(*r.Name, *r.Description, *p.Name, *p.Description) + } + table.AddSeparator() + } + table.EnableAutoMergeOnColumns(1, 2) + err := table.Display(cmd) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + } +} diff --git a/internal/cmd/project/role/list/list_test.go b/internal/cmd/project/role/list/list_test.go new file mode 100644 index 00000000..823471f1 --- /dev/null +++ b/internal/cmd/project/role/list/list_test.go @@ -0,0 +1,171 @@ +package list + +import ( + "context" + "testing" + + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/membership" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &membership.APIClient{} +var testProjectId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + limitFlag: "10", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + Limit: utils.Ptr(int64(10)), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *membership.ApiListRolesRequest)) membership.ApiListRolesRequest { + request := testClient.ListRoles(testCtx, projectResourceType, testProjectId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "limit invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "invalid" + }), + isValid: false, + }, + { + description: "limit invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := &cobra.Command{} + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + configureFlags(cmd) + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest membership.ApiListRolesRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/project/role/role.go b/internal/cmd/project/role/role.go new file mode 100644 index 00000000..ede1c2ba --- /dev/null +++ b/internal/cmd/project/role/role.go @@ -0,0 +1,25 @@ +package role + +import ( + "stackit/internal/cmd/project/role/list" + "stackit/internal/pkg/args" + "stackit/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "role", + Short: "Provides functionality regarding project roles", + Long: "Provides functionality regarding project roles", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd) + return cmd +} + +func addSubcommands(cmd *cobra.Command) { + cmd.AddCommand(list.NewCmd()) +} diff --git a/internal/cmd/project/update/update.go b/internal/cmd/project/update/update.go new file mode 100644 index 00000000..47aa3bb4 --- /dev/null +++ b/internal/cmd/project/update/update.go @@ -0,0 +1,154 @@ +package update + +import ( + "context" + "fmt" + "regexp" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/confirm" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/flags" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/projectname" + "stackit/internal/pkg/services/resourcemanager/client" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/resourcemanager" +) + +const ( + parentIdFlag = "parent-id" + nameFlag = "name" + labelFlag = "label" + + ownerRole = "project.owner" + labelKeyRegex = `[A-ZÄÜÖa-zäüöß0-9_-]{1,64}` + labelValueRegex = `^$|[A-ZÄÜÖa-zäüöß0-9_-]{1,64}` +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ParentId *string + Name *string + Labels *map[string]string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "update", + Short: "Update a STACKIT project", + Long: "Update a STACKIT project", + Args: args.NoArgs, + Example: examples.Build( + + examples.NewExample( + `Update the name of the configured STACKIT project`, + "$ stackit project update --name my-updated-project"), + examples.NewExample( + `Add labels to the configured STACKIT project`, + "$ stackit project update --label key=value,foo=bar"), + examples.NewExample( + `Update the name of a STACKIT project by explicitly providing the project ID`, + "$ stackit project update --name my-updated-project --project-id xxx"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, cmd) + if err != nil { + projectLabel = model.ProjectId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to update project %s?", projectLabel) + err = confirm.PromptForConfirmation(cmd, prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + _, err = req.Execute() + if err != nil { + return fmt.Errorf("update project: %w", err) + } + + cmd.Printf("Updated project %s\n", projectLabel) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(parentIdFlag, "", "Parent resource identifier. Both container ID (user-friendly) and UUID are supported") + cmd.Flags().String(nameFlag, "", "Project name") + cmd.Flags().StringToString(labelFlag, nil, "Labels are key-value string pairs which can be attached to a project. A label can be provided with the format key=value and the flag can be used multiple times to provide a list of labels") +} + +func parseInput(cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + labels := flags.FlagToStringToStringPointer(cmd, labelFlag) + parentId := flags.FlagToStringPointer(cmd, parentIdFlag) + name := flags.FlagToStringPointer(cmd, nameFlag) + + if labels == nil && parentId == nil && name == nil { + return nil, &errors.EmptyUpdateError{} + } + + if labels != nil { + labelKeyRegex := regexp.MustCompile(labelKeyRegex) + labelValueRegex := regexp.MustCompile(labelValueRegex) + for key, value := range *labels { + if !labelKeyRegex.MatchString(key) { + return nil, &errors.FlagValidationError{ + Flag: labelFlag, + Details: fmt.Sprintf("label key %s didn't match the required regex expression %s", key, labelKeyRegex), + } + } + + if !labelValueRegex.MatchString(value) { + return nil, &errors.FlagValidationError{ + Flag: labelFlag, + Details: fmt.Sprintf("label value %s for key %s didn't match the required regex expression %s", value, key, labelValueRegex), + } + } + } + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + ParentId: parentId, + Name: name, + Labels: labels, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *resourcemanager.APIClient) resourcemanager.ApiPartialUpdateProjectRequest { + req := apiClient.PartialUpdateProject(ctx, model.ProjectId) + req = req.PartialUpdateProjectPayload(resourcemanager.PartialUpdateProjectPayload{ + ContainerParentId: model.ParentId, + Name: model.Name, + Labels: model.Labels, + }) + + return req +} diff --git a/internal/cmd/project/update/update_test.go b/internal/cmd/project/update/update_test.go new file mode 100644 index 00000000..9fa61c81 --- /dev/null +++ b/internal/cmd/project/update/update_test.go @@ -0,0 +1,219 @@ +package update + +import ( + "context" + "testing" + + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/resourcemanager" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &resourcemanager.APIClient{} +var testProjectId = uuid.NewString() +var testParentId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + parentIdFlag: testParentId, + nameFlag: nameFlag, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + ParentId: utils.Ptr(testParentId), + Name: utils.Ptr(nameFlag), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *resourcemanager.ApiPartialUpdateProjectRequest)) resourcemanager.ApiPartialUpdateProjectRequest { + request := testClient.PartialUpdateProject(testCtx, testProjectId) + request = request.PartialUpdateProjectPayload(resourcemanager.PartialUpdateProjectPayload{ + ContainerParentId: utils.Ptr(testParentId), + Name: utils.Ptr(nameFlag), + }) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + labelValues []string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "required flags only (no values to update)", + flagValues: map[string]string{ + projectIdFlag: testProjectId, + }, + isValid: false, + expectedModel: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + }, + }, + { + description: "valid_labels", + flagValues: fixtureFlagValues(), + labelValues: []string{"key=value", "foo=bar"}, + expectedModel: fixtureInputModel( + func(model *inputModel) { + model.Labels = &map[string]string{ + "key": "value", + "foo": "bar", + } + }), + isValid: true, + }, + { + description: "valid_labels_2", + flagValues: fixtureFlagValues(), + labelValues: []string{"key=value,foo=bar"}, + expectedModel: fixtureInputModel( + func(model *inputModel) { + model.Labels = &map[string]string{ + "key": "value", + "foo": "bar", + } + }), + isValid: true, + }, + { + description: "invalid_labels", + flagValues: fixtureFlagValues(), + labelValues: []string{"key"}, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + for _, value := range tt.labelValues { + err := cmd.Flags().Set(labelFlag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", labelFlag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest resourcemanager.ApiPartialUpdateProjectRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "required fields only", + model: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + }, + expectedRequest: testClient.PartialUpdateProject(testCtx, testProjectId). + PartialUpdateProjectPayload(resourcemanager.PartialUpdateProjectPayload{}), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go new file mode 100644 index 00000000..8ccdcbb9 --- /dev/null +++ b/internal/cmd/root.go @@ -0,0 +1,154 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + "time" + + "stackit/internal/cmd/auth" + "stackit/internal/cmd/config" + "stackit/internal/cmd/curl" + "stackit/internal/cmd/dns" + "stackit/internal/cmd/mongodbflex" + "stackit/internal/cmd/organization" + "stackit/internal/cmd/project" + serviceaccount "stackit/internal/cmd/service-account" + "stackit/internal/cmd/ske" + "stackit/internal/pkg/args" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/flags" + "stackit/internal/pkg/globalflags" + + "github.com/spf13/cobra" +) + +func NewRootCmd(version, date string) *cobra.Command { + cmd := &cobra.Command{ + Use: "stackit", + Short: "Manage STACKIT resources using the command line", + Args: args.NoArgs, + SilenceErrors: true, // Error is beautified in a custom way before being printed + SilenceUsage: true, + DisableAutoGenTag: true, + RunE: func(cmd *cobra.Command, args []string) error { + if flags.FlagToBoolValue(cmd, "version") { + cmd.Printf("STACKIT CLI\n") + + parsedDate, err := time.Parse(time.RFC3339, date) + if err != nil { + cmd.Printf("Version: %s\n", version) + return nil + } + cmd.Printf("Version: %s (%s)\n", version, parsedDate.Format(time.DateOnly)) + return nil + } + + return cmd.Help() + }, + } + cmd.SetOut(os.Stdout) + + err := configureFlags(cmd) + cobra.CheckErr(err) + + addSubcommands(cmd) + + // Cobra creates the help flag with "help for " as the description + // We want to override that message by capitalizing the first letter to match the other flag descriptions + // See spf13/cobra#480 + traverseCommands(cmd, func(c *cobra.Command) { + c.Flags().BoolP("help", "h", false, fmt.Sprintf("Help for %q", c.CommandPath())) + }) + + return cmd +} + +func configureFlags(cmd *cobra.Command) error { + cmd.Flags().BoolP("version", "v", false, `Show "stackit" version`) + + err := globalflags.Configure(cmd.PersistentFlags()) + if err != nil { + return fmt.Errorf("configure global flags: %w", err) + } + return nil +} + +func addSubcommands(cmd *cobra.Command) { + cmd.AddCommand(auth.NewCmd()) + cmd.AddCommand(curl.NewCmd()) + cmd.AddCommand(config.NewCmd()) + cmd.AddCommand(organization.NewCmd()) + cmd.AddCommand(project.NewCmd()) + cmd.AddCommand(dns.NewCmd()) + cmd.AddCommand(mongodbflex.NewCmd()) + cmd.AddCommand(serviceaccount.NewCmd()) + cmd.AddCommand(ske.NewCmd()) +} + +// traverseCommands calls f for c and all of its children. +func traverseCommands(c *cobra.Command, f func(*cobra.Command)) { + f(c) + for _, c := range c.Commands() { + traverseCommands(c, f) + } +} + +func Execute(version, date string) { + cmd := NewRootCmd(version, date) + err := cmd.Execute() + if err != nil { + err := beautifyUnknownAndMissingCommandsError(cmd, err) + cmd.PrintErrln(cmd.ErrPrefix(), err.Error()) + os.Exit(1) + } +} + +// Returns a more user-friendly error if the input error is due to unknown/missing subcommands (issue: https://github.com/spf13/cobra/issues/706) +// +// Otherwise, returns the input error unchanged +func beautifyUnknownAndMissingCommandsError(rootCmd *cobra.Command, cmdErr error) error { + if !strings.HasPrefix(cmdErr.Error(), "unknown flag") { + return cmdErr + } + + cmd, unparsedInputs, err := rootCmd.Traverse(os.Args[1:]) + if err != nil { + return cmdErr + } + if len(unparsedInputs) == 0 { + // This shouldn't happen + // If we're here, Cobra was able to parse everything, thus it wouldn't raise "unknown flag" errors + return cmdErr + } + + // If cmd itself has more subcommands, we assume it has no logic by itself (other than --help) + // We want the error message to state that either a cmd's subcommand is missing, or that the cmd's subcommand called is wrong + if cmd.HasSubCommands() { + if strings.HasPrefix(unparsedInputs[0], "-") { + return &errors.SubcommandMissingError{ + Cmd: cmd, + } + } + + return &errors.InputUnknownError{ + ProvidedInput: unparsedInputs[0], + Cmd: cmd, + } + } + + // If we're here, cmd doesn't have subcommands command + // If Cobra raised "unknown flag" errors, then it was while parsing cmd's flags + // To be more user-friendly, we add a usage tip + err = cmd.ParseFlags(unparsedInputs) + if err != nil { + return errors.AppendUsageTip(err, cmd) + } + + // This shouldn't happen + // If we're here, Cobra was able to parse cmd's flags, thus it wouldn't raise "unknown flag" errors + return &errors.InputUnknownError{ + ProvidedInput: unparsedInputs[0], + Cmd: cmd, + } +} diff --git a/internal/cmd/service-account/create/create.go b/internal/cmd/service-account/create/create.go new file mode 100644 index 00000000..401d5266 --- /dev/null +++ b/internal/cmd/service-account/create/create.go @@ -0,0 +1,106 @@ +package create + +import ( + "context" + "fmt" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/confirm" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/flags" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/projectname" + "stackit/internal/pkg/services/service-account/client" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/serviceaccount" +) + +const ( + nameFlag = "name" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Name *string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Create a service account", + Long: "Create a service account", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create a service account with name "my-service-account"`, + "$ stackit service-account create --name my-service-account"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, cmd) + if err != nil { + projectLabel = model.ProjectId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to create a service account for project %s?", projectLabel) + err = confirm.PromptForConfirmation(cmd, prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create service account: %w", err) + } + + cmd.Printf("Created service account for project %s. Email %s\n", projectLabel, *resp.Email) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().StringP(nameFlag, "n", "", "Service account name. A unique email will be generated from this name") + + err := flags.MarkFlagsRequired(cmd, nameFlag) + cobra.CheckErr(err) +} + +func parseInput(cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + Name: flags.FlagToStringPointer(cmd, nameFlag), + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *serviceaccount.APIClient) serviceaccount.ApiCreateServiceAccountRequest { + req := apiClient.CreateServiceAccount(ctx, model.ProjectId) + req = req.CreateServiceAccountPayload(serviceaccount.CreateServiceAccountPayload{ + Name: model.Name, + }) + return req +} diff --git a/internal/cmd/service-account/create/create_test.go b/internal/cmd/service-account/create/create_test.go new file mode 100644 index 00000000..1304d06e --- /dev/null +++ b/internal/cmd/service-account/create/create_test.go @@ -0,0 +1,186 @@ +package create + +import ( + "context" + "testing" + + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/serviceaccount" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &serviceaccount.APIClient{} +var testProjectId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + nameFlag: "example", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + Name: utils.Ptr("example"), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *serviceaccount.ApiCreateServiceAccountRequest)) serviceaccount.ApiCreateServiceAccountRequest { + request := testClient.CreateServiceAccount(testCtx, testProjectId) + request = request.CreateServiceAccountPayload(serviceaccount.CreateServiceAccountPayload{ + Name: utils.Ptr("example"), + }) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + primaryFlagValues []string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "zero values", + flagValues: map[string]string{ + projectIdFlag: testProjectId, + nameFlag: "", + }, + isValid: true, + expectedModel: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + Name: utils.Ptr(""), + }, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest serviceaccount.ApiCreateServiceAccountRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/service-account/delete/delete.go b/internal/cmd/service-account/delete/delete.go new file mode 100644 index 00000000..5776e416 --- /dev/null +++ b/internal/cmd/service-account/delete/delete.go @@ -0,0 +1,93 @@ +package delete + +import ( + "context" + "fmt" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/confirm" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/services/service-account/client" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/serviceaccount" +) + +const ( + emailArg = "EMAIL" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Email string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", emailArg), + Short: "Delete a service account", + Long: "Delete a service account", + Args: args.SingleArg(emailArg, nil), + Example: examples.Build( + examples.NewExample( + `Delete a service account with email "my-service-account-1234567@sa.stackit.cloud"`, + "$ stackit service-account delete my-service-account-1234567@sa.stackit.cloud"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to delete service account %s? (This cannot be undone)", model.Email) + err = confirm.PromptForConfirmation(cmd, prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + if err != nil { + return err + } + err = req.Execute() + if err != nil { + return fmt.Errorf("delete service account: %w", err) + } + + cmd.Printf("Service account %s deleted", model.Email) + return nil + }, + } + return cmd +} + +func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + email := inputArgs[0] + + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + Email: email, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *serviceaccount.APIClient) serviceaccount.ApiDeleteServiceAccountRequest { + req := apiClient.DeleteServiceAccount(ctx, model.ProjectId, model.Email) + return req +} diff --git a/internal/cmd/service-account/delete/delete_test.go b/internal/cmd/service-account/delete/delete_test.go new file mode 100644 index 00000000..1d3ba353 --- /dev/null +++ b/internal/cmd/service-account/delete/delete_test.go @@ -0,0 +1,205 @@ +package delete + +import ( + "context" + "testing" + + "stackit/internal/pkg/globalflags" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/serviceaccount" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &serviceaccount.APIClient{} +var testProjectId = uuid.NewString() +var testEmail = "service-account-email-1234567@sa.stackit.cloud" + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testEmail, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + Email: testEmail, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *serviceaccount.ApiDeleteServiceAccountRequest)) serviceaccount.ApiDeleteServiceAccountRequest { + request := testClient.DeleteServiceAccount(testCtx, testProjectId, testEmail) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing input: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + isValid bool + expectedRequest serviceaccount.ApiDeleteServiceAccountRequest + }{ + { + description: "base", + model: fixtureInputModel(), + isValid: true, + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/service-account/get-jwks/get_jwks.go b/internal/cmd/service-account/get-jwks/get_jwks.go new file mode 100644 index 00000000..9b75403a --- /dev/null +++ b/internal/cmd/service-account/get-jwks/get_jwks.go @@ -0,0 +1,87 @@ +package getjwks + +import ( + "context" + "encoding/json" + "fmt" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/services/service-account/client" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/serviceaccount" +) + +const ( + emailArg = "EMAIL" +) + +type inputModel struct { + Email string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("get-jwks %s", emailArg), + Short: "Get JWKS for a service account", + Long: "Get JSON Web Key set (JWKS) for a service account. Only JSON output is supported", + Args: args.SingleArg(emailArg, nil), + Example: examples.Build( + examples.NewExample( + `Get JWKS for the service account with email "my-service-account-1234567@sa.stackit.cloud"`, + "$ stackit service-account get-jwks my-service-account-1234567@sa.stackit.cloud"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get JWKS: %w", err) + } + jwks := *resp.Keys + if len(jwks) == 0 { + cmd.Printf("Empty JWKS for service account %s\n", model.Email) + return nil + } + + return outputResult(cmd, jwks) + }, + } + + return cmd +} + +func parseInput(_ *cobra.Command, inputArgs []string) (*inputModel, error) { + email := inputArgs[0] + + return &inputModel{ + Email: email, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *serviceaccount.APIClient) serviceaccount.ApiGetJWKSRequest { + req := apiClient.GetJWKS(ctx, model.Email) + return req +} + +func outputResult(cmd *cobra.Command, serviceAccounts []serviceaccount.JWK) error { + details, err := json.MarshalIndent(serviceAccounts, "", " ") + if err != nil { + return fmt.Errorf("marshal JWK list: %w", err) + } + cmd.Println(string(details)) + return nil +} diff --git a/internal/cmd/service-account/get-jwks/get_jwks_test.go b/internal/cmd/service-account/get-jwks/get_jwks_test.go new file mode 100644 index 00000000..9f5d2531 --- /dev/null +++ b/internal/cmd/service-account/get-jwks/get_jwks_test.go @@ -0,0 +1,137 @@ +package getjwks + +import ( + "context" + "testing" + + "stackit/internal/pkg/globalflags" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/stackitcloud/stackit-sdk-go/services/serviceaccount" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &serviceaccount.APIClient{} +var testEmail = "service-account-email-1234567@sa.stackit.cloud" + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testEmail, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + Email: testEmail, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *serviceaccount.ApiGetJWKSRequest)) serviceaccount.ApiGetJWKSRequest { + request := testClient.GetJWKS(testCtx, testEmail) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: []string{}, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing input: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest serviceaccount.ApiGetJWKSRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/service-account/key/create/create.go b/internal/cmd/service-account/key/create/create.go new file mode 100644 index 00000000..cabd0508 --- /dev/null +++ b/internal/cmd/service-account/key/create/create.go @@ -0,0 +1,162 @@ +package create + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/confirm" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/flags" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/services/service-account/client" + "stackit/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/serviceaccount" +) + +const ( + serviceAccountEmailFlag = "email" + expiredInDaysFlag = "expires-in-days" + publicKeyFlag = "public-key" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + + ServiceAccountEmail string + ExpiresInDays *int64 + PublicKey *string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Create a service account key", + Long: fmt.Sprintf("%s\n%s\n%s", + "Create a service account key.", + "You can generate an RSA keypair and provide the public key.", + "If you do not provide a public key, the service will generate a new key-pair and the private key is included in the response. You won't be able to retrieve it later.", + ), + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create a key for the service account with email "my-service-account-1234567@sa.stackit.cloud with no expiration date"`, + "$ stackit service-account key create --email my-service-account-1234567@sa.stackit.cloud"), + examples.NewExample( + `Create a key for the service account with email "my-service-account-1234567@sa.stackit.cloud" expiring in 10 days`, + "$ stackit service-account key create --email my-service-account-1234567@sa.stackit.cloud --expires-in-days 10"), + examples.NewExample( + `Create a key for the service account with email "my-service-account-1234567@sa.stackit.cloud" and provide the public key in a .pem file"`, + `$ stackit service-account key create --email my-service-account-1234567@sa.stackit.cloud --public-key @./public.pem`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + if !model.AssumeYes { + validUntilInfo := "The key will be valid until deleted" + if model.ExpiresInDays != nil { + validUntilInfo = fmt.Sprintf("The key will be valid for %d days", *model.ExpiresInDays) + } + prompt := fmt.Sprintf("Are you sure you want to create a key for service account %s? %s", model.ServiceAccountEmail, validUntilInfo) + err = confirm.PromptForConfirmation(cmd, prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient, time.Now()) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create service account key: %w", err) + } + + cmd.Printf("Created key for service account %s\n", model.ServiceAccountEmail) + + key, err := json.MarshalIndent(resp, "", " ") + if err != nil { + return fmt.Errorf("marshal key: %w", err) + } + cmd.Println(string(key)) + return nil + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().StringP(serviceAccountEmailFlag, "e", "", "Service account email") + cmd.Flags().Int64P(expiredInDaysFlag, "", 0, "Number of days until expiration. When omitted, the key is valid until deleted") + cmd.Flags().Var(flags.ReadFromFileFlag(), publicKeyFlag, `Public key of the user generated RSA 2048 key-pair. Must be in x509 format. Can be a string or path to the .pem file, if prefixed with "@"`) + + err := flags.MarkFlagsRequired(cmd, serviceAccountEmailFlag) + cobra.CheckErr(err) +} + +func parseInput(cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + email := flags.FlagToStringValue(cmd, serviceAccountEmailFlag) + if email == "" { + return nil, &errors.FlagValidationError{ + Flag: serviceAccountEmailFlag, + Details: "can't be empty", + } + } + + expriresInDays := flags.FlagToInt64Pointer(cmd, expiredInDaysFlag) + if expriresInDays != nil && *expriresInDays < 1 { + return nil, &errors.FlagValidationError{ + Flag: expiredInDaysFlag, + Details: "must be greater than 0", + } + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + ServiceAccountEmail: email, + ExpiresInDays: expriresInDays, + PublicKey: flags.FlagToStringPointer(cmd, publicKeyFlag), + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *serviceaccount.APIClient, now time.Time) serviceaccount.ApiCreateServiceAccountKeyRequest { + req := apiClient.CreateServiceAccountKey(ctx, model.ProjectId, model.ServiceAccountEmail) + + var validUntil *time.Time + validUntil = nil + if model.ExpiresInDays != nil { + validUntil = utils.Ptr(daysFromNow(now, *model.ExpiresInDays)) + } + + req = req.CreateServiceAccountKeyPayload(serviceaccount.CreateServiceAccountKeyPayload{ + ValidUntil: validUntil, + PublicKey: model.PublicKey, + }) + return req +} + +func daysFromNow(now time.Time, days int64) time.Time { + validUntil := now.AddDate(0, 0, int(days)) + return validUntil +} diff --git a/internal/cmd/service-account/key/create/create_test.go b/internal/cmd/service-account/key/create/create_test.go new file mode 100644 index 00000000..6b74a874 --- /dev/null +++ b/internal/cmd/service-account/key/create/create_test.go @@ -0,0 +1,235 @@ +package create + +import ( + "context" + "testing" + "time" + + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/serviceaccount" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &serviceaccount.APIClient{} +var testProjectId = uuid.NewString() +var testServiceAccountEmail = "my-service-account-1234567@sa.stackit.cloud" +var testNow = time.Now() +var test10DaysFromNow = daysFromNow(testNow, 10) +var testPublicKey = "my-public-key" + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + serviceAccountEmailFlag: testServiceAccountEmail, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + ServiceAccountEmail: testServiceAccountEmail, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *serviceaccount.ApiCreateServiceAccountKeyRequest)) serviceaccount.ApiCreateServiceAccountKeyRequest { + request := testClient.CreateServiceAccountKey(testCtx, testProjectId, testServiceAccountEmail) + request = request.CreateServiceAccountKeyPayload(serviceaccount.CreateServiceAccountKeyPayload{}) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "with expiring date", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[expiredInDaysFlag] = "10" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.ExpiresInDays = utils.Ptr(int64(10)) + }), + }, + { + description: "with public key", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[publicKeyFlag] = testPublicKey + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.PublicKey = utils.Ptr(testPublicKey) + }), + }, + { + description: "with public key", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[publicKeyFlag] = "" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.PublicKey = utils.Ptr("") + }), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "service account email missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, serviceAccountEmailFlag) + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + isValid bool + expectedRequest serviceaccount.ApiCreateServiceAccountKeyRequest + }{ + { + description: "base", + model: fixtureInputModel(), + isValid: true, + expectedRequest: fixtureRequest(), + }, + { + description: "with expiring date", + model: fixtureInputModel(func(model *inputModel) { + model.ExpiresInDays = utils.Ptr(int64(10)) + }), + isValid: true, + expectedRequest: fixtureRequest().CreateServiceAccountKeyPayload( + serviceaccount.CreateServiceAccountKeyPayload{ + ValidUntil: utils.Ptr(test10DaysFromNow), + }), + }, + { + description: "with public key", + model: fixtureInputModel(func(model *inputModel) { + model.PublicKey = utils.Ptr(testPublicKey) + }), + isValid: true, + expectedRequest: fixtureRequest().CreateServiceAccountKeyPayload( + serviceaccount.CreateServiceAccountKeyPayload{ + PublicKey: utils.Ptr(testPublicKey), + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient, testNow) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/service-account/key/delete/delete.go b/internal/cmd/service-account/key/delete/delete.go new file mode 100644 index 00000000..74d0182c --- /dev/null +++ b/internal/cmd/service-account/key/delete/delete.go @@ -0,0 +1,114 @@ +package delete + +import ( + "context" + "fmt" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/confirm" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/flags" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/services/service-account/client" + "stackit/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/serviceaccount" +) + +const ( + keyIdArg = "KEY_ID" + + serviceAccountEmailFlag = "email" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + + ServiceAccountEmail string + KeyId string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", keyIdArg), + Short: "Delete a service account key", + Long: "Delete a service account key.", + Args: args.SingleArg(keyIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Delete a key with ID "xxx" belonging to the service account with email "my-service-account-1234567@sa.stackit.cloud"`, + "$ stackit service-account key delete xxx --email my-service-account-1234567@sa.stackit.cloud"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to delete the key %s from service account %s?", model.KeyId, model.ServiceAccountEmail) + err = confirm.PromptForConfirmation(cmd, prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + err = req.Execute() + if err != nil { + return fmt.Errorf("delete key: %w", err) + } + + cmd.Printf("Deleted key %s from service account %s\n", model.KeyId, model.ServiceAccountEmail) + return nil + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().StringP(serviceAccountEmailFlag, "e", "", "Service account email") + + err := flags.MarkFlagsRequired(cmd, serviceAccountEmailFlag) + cobra.CheckErr(err) +} + +func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + keyId := inputArgs[0] + + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + email := flags.FlagToStringValue(cmd, serviceAccountEmailFlag) + if email == "" { + return nil, &errors.FlagValidationError{ + Flag: serviceAccountEmailFlag, + Details: "can't be empty", + } + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + ServiceAccountEmail: email, + KeyId: keyId, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *serviceaccount.APIClient) serviceaccount.ApiDeleteServiceAccountKeyRequest { + req := apiClient.DeleteServiceAccountKey(ctx, model.ProjectId, model.ServiceAccountEmail, model.KeyId) + return req +} diff --git a/internal/cmd/service-account/key/delete/delete_test.go b/internal/cmd/service-account/key/delete/delete_test.go new file mode 100644 index 00000000..951fea74 --- /dev/null +++ b/internal/cmd/service-account/key/delete/delete_test.go @@ -0,0 +1,228 @@ +package delete + +import ( + "context" + "testing" + + "stackit/internal/pkg/globalflags" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/serviceaccount" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &serviceaccount.APIClient{} +var testProjectId = uuid.NewString() +var testServiceAccountEmail = "my-service-account-1234567@sa.stackit.cloud" +var testKeyId = uuid.NewString() + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testKeyId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + serviceAccountEmailFlag: testServiceAccountEmail, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + ServiceAccountEmail: testServiceAccountEmail, + KeyId: testKeyId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *serviceaccount.ApiDeleteServiceAccountKeyRequest)) serviceaccount.ApiDeleteServiceAccountKeyRequest { + request := testClient.DeleteServiceAccountKey(testCtx, testProjectId, testServiceAccountEmail, testKeyId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "service account email missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, serviceAccountEmailFlag) + }), + isValid: false, + }, + { + description: "key id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "key id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing input: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + isValid bool + expectedRequest serviceaccount.ApiDeleteServiceAccountKeyRequest + }{ + { + description: "base", + model: fixtureInputModel(), + isValid: true, + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/service-account/key/describe/describe.go b/internal/cmd/service-account/key/describe/describe.go new file mode 100644 index 00000000..d0c1fd3b --- /dev/null +++ b/internal/cmd/service-account/key/describe/describe.go @@ -0,0 +1,112 @@ +package describe + +import ( + "context" + "encoding/json" + "fmt" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/flags" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/services/service-account/client" + "stackit/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/serviceaccount" +) + +const ( + keyIdArg = "KEY_ID" + + serviceAccountEmailFlag = "email" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + + ServiceAccountEmail string + KeyId string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", keyIdArg), + Short: "Get details of a service account key", + Long: "Get details of a service account key. Only JSON output is supported.", + Args: args.SingleArg(keyIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Get details of a service account key with ID "xxx" belonging to the service account with email "my-service-account-1234567@sa.stackit.cloud"`, + "$ stackit service-account key describe xxx --email my-service-account-1234567@sa.stackit.cloud"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd, args) + if err != nil { + return err + } + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("read service account key: %w", err) + } + + return outputResult(cmd, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().StringP(serviceAccountEmailFlag, "e", "", "Service account email") + + err := flags.MarkFlagsRequired(cmd, serviceAccountEmailFlag) + cobra.CheckErr(err) +} + +func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + keyId := inputArgs[0] + + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + email := flags.FlagToStringValue(cmd, serviceAccountEmailFlag) + if email == "" { + return nil, &errors.FlagValidationError{ + Flag: serviceAccountEmailFlag, + Details: "can't be empty", + } + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + ServiceAccountEmail: email, + KeyId: keyId, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *serviceaccount.APIClient) serviceaccount.ApiGetServiceAccountKeyRequest { + req := apiClient.GetServiceAccountKey(ctx, model.ProjectId, model.ServiceAccountEmail, model.KeyId) + return req +} + +func outputResult(cmd *cobra.Command, key *serviceaccount.GetServiceAccountKeyResponse) error { + marshaledKey, err := json.MarshalIndent(key, "", " ") + if err != nil { + return fmt.Errorf("marshal service account key: %w", err) + } + cmd.Println(string(marshaledKey)) + return nil +} diff --git a/internal/cmd/service-account/key/describe/describe_test.go b/internal/cmd/service-account/key/describe/describe_test.go new file mode 100644 index 00000000..f76a33d9 --- /dev/null +++ b/internal/cmd/service-account/key/describe/describe_test.go @@ -0,0 +1,228 @@ +package describe + +import ( + "context" + "testing" + + "stackit/internal/pkg/globalflags" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/serviceaccount" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &serviceaccount.APIClient{} +var testProjectId = uuid.NewString() +var testServiceAccountEmail = "my-service-account-1234567@sa.stackit.cloud" +var testKeyId = uuid.NewString() + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testKeyId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + serviceAccountEmailFlag: testServiceAccountEmail, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + ServiceAccountEmail: testServiceAccountEmail, + KeyId: testKeyId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *serviceaccount.ApiGetServiceAccountKeyRequest)) serviceaccount.ApiGetServiceAccountKeyRequest { + request := testClient.GetServiceAccountKey(testCtx, testProjectId, testServiceAccountEmail, testKeyId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "service account email missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, serviceAccountEmailFlag) + }), + isValid: false, + }, + { + description: "key id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "key id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing input: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + isValid bool + expectedRequest serviceaccount.ApiGetServiceAccountKeyRequest + }{ + { + description: "base", + model: fixtureInputModel(), + isValid: true, + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/service-account/key/key.go b/internal/cmd/service-account/key/key.go new file mode 100644 index 00000000..85367225 --- /dev/null +++ b/internal/cmd/service-account/key/key.go @@ -0,0 +1,33 @@ +package key + +import ( + "stackit/internal/cmd/service-account/key/create" + "stackit/internal/cmd/service-account/key/delete" + "stackit/internal/cmd/service-account/key/describe" + "stackit/internal/cmd/service-account/key/list" + "stackit/internal/cmd/service-account/key/update" + "stackit/internal/pkg/args" + "stackit/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "key", + Short: "Provides functionality regarding service account keys", + Long: "Provides functionality regarding service account keys", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd) + return cmd +} + +func addSubcommands(cmd *cobra.Command) { + cmd.AddCommand(create.NewCmd()) + cmd.AddCommand(delete.NewCmd()) + cmd.AddCommand(describe.NewCmd()) + cmd.AddCommand(list.NewCmd()) + cmd.AddCommand(update.NewCmd()) +} diff --git a/internal/cmd/service-account/key/list/list.go b/internal/cmd/service-account/key/list/list.go new file mode 100644 index 00000000..82ac7d0d --- /dev/null +++ b/internal/cmd/service-account/key/list/list.go @@ -0,0 +1,157 @@ +package list + +import ( + "context" + "encoding/json" + "fmt" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/flags" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/services/service-account/client" + "stackit/internal/pkg/tables" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/serviceaccount" +) + +const ( + limitFlag = "limit" + serviceAccountEmailFlag = "email" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + + ServiceAccountEmail string + Limit *int64 +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List all service account keys", + Long: "List all service account keys.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all keys belonging to the service account with email "my-service-account-1234567@sa.stackit.cloud"`, + "$ stackit service-account key list --email my-service-account-1234567@sa.stackit.cloud"), + examples.NewExample( + `List all keys belonging to the service account with email "my-service-account-1234567@sa.stackit.cloud" in JSON format`, + "$ stackit service-account key list --email my-service-account-1234567@sa.stackit.cloud --output-format json"), + examples.NewExample( + `List up to 10 keys belonging to the service account with email "my-service-account-1234567@sa.stackit.cloud"`, + "$ stackit service-account key list --email my-service-account-1234567@sa.stackit.cloud --limit 10"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("list keys metadata: %w", err) + } + keys := *resp.Items + if len(keys) == 0 { + cmd.Printf("No keys found for service account %s\n", model.ServiceAccountEmail) + return nil + } + + // Truncate output + if model.Limit != nil && len(keys) > int(*model.Limit) { + keys = keys[:*model.Limit] + } + + return outputResult(cmd, model.OutputFormat, keys) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().StringP(serviceAccountEmailFlag, "e", "", "Service account email") + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") + + err := flags.MarkFlagsRequired(cmd, serviceAccountEmailFlag) + cobra.CheckErr(err) +} + +func parseInput(cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + email := flags.FlagToStringValue(cmd, serviceAccountEmailFlag) + if email == "" { + return nil, &errors.FlagValidationError{ + Flag: serviceAccountEmailFlag, + Details: "can't be empty", + } + } + + limit := flags.FlagToInt64Pointer(cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + ServiceAccountEmail: email, + Limit: limit, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *serviceaccount.APIClient) serviceaccount.ApiListServiceAccountKeysRequest { + req := apiClient.ListServiceAccountKeys(ctx, model.ProjectId, model.ServiceAccountEmail) + return req +} + +func outputResult(cmd *cobra.Command, outputFormat string, keys []serviceaccount.ServiceAccountKeyListResponse) error { + switch outputFormat { + case globalflags.JSONOutputFormat: + details, err := json.MarshalIndent(keys, "", " ") + if err != nil { + return fmt.Errorf("marshal keys metadata: %w", err) + } + cmd.Println(string(details)) + + return nil + default: + table := tables.NewTable() + table.SetHeader("ID", "ACTIVE", "CREATED_AT", "VALID_UNTIL") + for i := range keys { + k := keys[i] + validUntil := "does not expire" + if k.ValidUntil != nil { + validUntil = k.ValidUntil.String() + } + table.AddRow(*k.Id, *k.Active, *k.CreatedAt, validUntil) + } + err := table.Display(cmd) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + } +} diff --git a/internal/cmd/service-account/key/list/list_test.go b/internal/cmd/service-account/key/list/list_test.go new file mode 100644 index 00000000..26541917 --- /dev/null +++ b/internal/cmd/service-account/key/list/list_test.go @@ -0,0 +1,195 @@ +package list + +import ( + "context" + "testing" + + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/serviceaccount" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &serviceaccount.APIClient{} +var testProjectId = uuid.NewString() +var testServiceAccountEmail = "my-service-account-1234567@sa.stackit.cloud" + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + serviceAccountEmailFlag: testServiceAccountEmail, + limitFlag: "10", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + ServiceAccountEmail: testServiceAccountEmail, + Limit: utils.Ptr(int64(10)), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *serviceaccount.ApiListServiceAccountKeysRequest)) serviceaccount.ApiListServiceAccountKeysRequest { + request := testClient.ListServiceAccountKeys(testCtx, testProjectId, testServiceAccountEmail) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "service account email missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, serviceAccountEmailFlag) + }), + isValid: false, + }, + { + description: "limit invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "invalid" + }), + isValid: false, + }, + { + description: "limit invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := &cobra.Command{} + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + configureFlags(cmd) + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest serviceaccount.ApiListServiceAccountKeysRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/service-account/key/update/update.go b/internal/cmd/service-account/key/update/update.go new file mode 100644 index 00000000..782b41be --- /dev/null +++ b/internal/cmd/service-account/key/update/update.go @@ -0,0 +1,180 @@ +package update + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/confirm" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/flags" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/services/service-account/client" + "stackit/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/serviceaccount" +) + +const ( + keyIdArg = "KEY_ID" + + serviceAccountEmailFlag = "email" + expiredInDaysFlag = "expires-in-days" + activateFlag = "activate" + deactivateFlag = "deactivate" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + + ServiceAccountEmail string + KeyId string + ExpiresInDays *int64 + Activate bool + Deactivate bool +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("update %s", keyIdArg), + Short: "Update a service account key", + Long: fmt.Sprintf("%s\n%s", + "Update a service account key.", + "You can temporarily activate or deactivate the key and/or update its date of expiration.", + ), + Args: args.SingleArg(keyIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Temporarily deactivate a key with ID "xxx" of the service account with email "my-service-account-1234567@sa.stackit.cloud"`, + "$ stackit service-account key update xxx --email my-service-account-1234567@sa.stackit.cloud --deactivate"), + examples.NewExample( + `Activate a key of the service account with email "my-service-account-1234567@sa.stackit.cloud"`, + "$ stackit service-account key update xxx --email my-service-account-1234567@sa.stackit.cloud --activate"), + examples.NewExample( + `Update the expiration date of a key of the service account with email "my-service-account-1234567@sa.stackit.cloud"`, + "$ stackit service-account key update xxx --email my-service-account-1234567@sa.stackit.cloud --expires-in-days 30"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to update the key with ID %s?", model.KeyId) + err = confirm.PromptForConfirmation(cmd, prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient, time.Now()) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create service account key: %w", err) + } + + key, err := json.MarshalIndent(resp, "", " ") + if err != nil { + return fmt.Errorf("marshal key: %w", err) + } + cmd.Println(string(key)) + return nil + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().StringP(serviceAccountEmailFlag, "e", "", "Service account email") + cmd.Flags().Int64P(expiredInDaysFlag, "", 0, "Number of days until expiration") + cmd.Flags().Bool(activateFlag, false, "If set, activates the service account key") + cmd.Flags().Bool(deactivateFlag, false, "If set, temporarily deactivates the service account key") + + err := flags.MarkFlagsRequired(cmd, serviceAccountEmailFlag) + cobra.CheckErr(err) +} + +func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + keyId := inputArgs[0] + + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + email := flags.FlagToStringValue(cmd, serviceAccountEmailFlag) + if email == "" { + return nil, &errors.FlagValidationError{ + Flag: serviceAccountEmailFlag, + Details: "can't be empty", + } + } + + expriresInDays := flags.FlagToInt64Pointer(cmd, expiredInDaysFlag) + if expriresInDays != nil && *expriresInDays < 1 { + return nil, &errors.FlagValidationError{ + Flag: expiredInDaysFlag, + Details: "must be greater than 0", + } + } + + activate := flags.FlagToBoolValue(cmd, activateFlag) + deactivate := flags.FlagToBoolValue(cmd, deactivateFlag) + if activate && deactivate { + return nil, fmt.Errorf("only one of %q and %q can be set", activateFlag, deactivateFlag) + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + ServiceAccountEmail: email, + KeyId: keyId, + ExpiresInDays: expriresInDays, + Activate: activate, + Deactivate: deactivate, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *serviceaccount.APIClient, now time.Time) serviceaccount.ApiPartialUpdateServiceAccountKeyRequest { + req := apiClient.PartialUpdateServiceAccountKey(ctx, model.ProjectId, model.ServiceAccountEmail, model.KeyId) + + var validUntil *time.Time + validUntil = nil + if model.ExpiresInDays != nil { + validUntil = utils.Ptr(daysFromNow(now, *model.ExpiresInDays)) + } + + var active *bool + active = nil + if model.Deactivate { + active = utils.Ptr(false) + } + if model.Activate { + active = utils.Ptr(true) + } + + req = req.PartialUpdateServiceAccountKeyPayload(serviceaccount.PartialUpdateServiceAccountKeyPayload{ + ValidUntil: validUntil, + Active: active, + }) + return req +} + +func daysFromNow(now time.Time, days int64) time.Time { + validUntil := now.AddDate(0, 0, int(days)) + return validUntil +} diff --git a/internal/cmd/service-account/key/update/update_test.go b/internal/cmd/service-account/key/update/update_test.go new file mode 100644 index 00000000..5b5855ad --- /dev/null +++ b/internal/cmd/service-account/key/update/update_test.go @@ -0,0 +1,308 @@ +package update + +import ( + "context" + "testing" + "time" + + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/serviceaccount" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &serviceaccount.APIClient{} +var testProjectId = uuid.NewString() +var testServiceAccountEmail = "my-service-account-1234567@sa.stackit.cloud" +var testKeyId = uuid.NewString() +var testNow = time.Now() +var test10DaysFromNow = daysFromNow(testNow, 10) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testKeyId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + serviceAccountEmailFlag: testServiceAccountEmail, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + ServiceAccountEmail: testServiceAccountEmail, + KeyId: testKeyId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *serviceaccount.ApiPartialUpdateServiceAccountKeyRequest)) serviceaccount.ApiPartialUpdateServiceAccountKeyRequest { + request := testClient.PartialUpdateServiceAccountKey(testCtx, testProjectId, testServiceAccountEmail, testKeyId) + request = request.PartialUpdateServiceAccountKeyPayload(serviceaccount.PartialUpdateServiceAccountKeyPayload{}) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "with expiring date", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[expiredInDaysFlag] = "10" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.ExpiresInDays = utils.Ptr(int64(10)) + }), + }, + { + description: "with activate flag", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[activateFlag] = "true" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Activate = true + }), + }, + { + description: "with deactivate flag", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[deactivateFlag] = "true" + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Deactivate = true + }), + }, + { + description: "with activate and deactivate flags", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[activateFlag] = "true" + flagValues[deactivateFlag] = "true" + }), + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "service account email missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, serviceAccountEmailFlag) + }), + isValid: false, + }, + { + description: "key id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "key id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + isValid bool + expectedRequest serviceaccount.ApiPartialUpdateServiceAccountKeyRequest + }{ + { + description: "base", + model: fixtureInputModel(), + isValid: true, + expectedRequest: fixtureRequest(), + }, + { + description: "with expiring date", + model: fixtureInputModel(func(model *inputModel) { + model.ExpiresInDays = utils.Ptr(int64(10)) + }), + isValid: true, + expectedRequest: fixtureRequest().PartialUpdateServiceAccountKeyPayload( + serviceaccount.PartialUpdateServiceAccountKeyPayload{ + ValidUntil: utils.Ptr(test10DaysFromNow), + }), + }, + { + description: "with activate flag", + model: fixtureInputModel(func(model *inputModel) { + model.Activate = true + }), + isValid: true, + expectedRequest: fixtureRequest().PartialUpdateServiceAccountKeyPayload( + serviceaccount.PartialUpdateServiceAccountKeyPayload{ + Active: utils.Ptr(true), + }), + }, + { + description: "with deactivate flag", + model: fixtureInputModel(func(model *inputModel) { + model.Deactivate = true + }), + isValid: true, + expectedRequest: fixtureRequest().PartialUpdateServiceAccountKeyPayload( + serviceaccount.PartialUpdateServiceAccountKeyPayload{ + Active: utils.Ptr(false), + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient, testNow) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/service-account/list/list.go b/internal/cmd/service-account/list/list.go new file mode 100644 index 00000000..6e5f92eb --- /dev/null +++ b/internal/cmd/service-account/list/list.go @@ -0,0 +1,134 @@ +package list + +import ( + "context" + "encoding/json" + "fmt" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/flags" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/projectname" + "stackit/internal/pkg/services/service-account/client" + "stackit/internal/pkg/tables" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/serviceaccount" +) + +const ( + limitFlag = "limit" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Limit *int64 +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List all service accounts", + Long: "List all service accounts", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all service accounts`, + "$ stackit service-account list"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("list service accounts: %w", err) + } + serviceAccounts := *resp.Items + if len(serviceAccounts) == 0 { + projectLabel, err := projectname.GetProjectName(ctx, cmd) + if err != nil { + projectLabel = model.ProjectId + } + cmd.Printf("No service accounts found for project %s\n", projectLabel) + return nil + } + + // Truncate output + if model.Limit != nil && len(serviceAccounts) > int(*model.Limit) { + serviceAccounts = serviceAccounts[:*model.Limit] + } + + return outputResult(cmd, model.OutputFormat, serviceAccounts) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") +} + +func parseInput(cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + limit := flags.FlagToInt64Pointer(cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + Limit: flags.FlagToInt64Pointer(cmd, limitFlag), + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *serviceaccount.APIClient) serviceaccount.ApiListServiceAccountsRequest { + req := apiClient.ListServiceAccounts(ctx, model.ProjectId) + return req +} + +func outputResult(cmd *cobra.Command, outputFormat string, serviceAccounts []serviceaccount.ServiceAccount) error { + switch outputFormat { + case globalflags.JSONOutputFormat: + details, err := json.MarshalIndent(serviceAccounts, "", " ") + if err != nil { + return fmt.Errorf("marshal service accounts list: %w", err) + } + cmd.Println(string(details)) + default: + table := tables.NewTable() + table.SetHeader("ID", "EMAIL") + for i := range serviceAccounts { + account := serviceAccounts[i] + table.AddRow(*account.Id, *account.Email) + } + err := table.Display(cmd) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + } + + return nil +} diff --git a/internal/cmd/service-account/list/list_test.go b/internal/cmd/service-account/list/list_test.go new file mode 100644 index 00000000..689f1c66 --- /dev/null +++ b/internal/cmd/service-account/list/list_test.go @@ -0,0 +1,185 @@ +package list + +import ( + "context" + "testing" + + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/serviceaccount" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &serviceaccount.APIClient{} +var testProjectId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + limitFlag: "10", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + Limit: utils.Ptr(int64(10)), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *serviceaccount.ApiListServiceAccountsRequest)) serviceaccount.ApiListServiceAccountsRequest { + request := testClient.ListServiceAccounts(testCtx, testProjectId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "limit invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "invalid" + }), + isValid: false, + }, + { + description: "limit invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := &cobra.Command{} + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + configureFlags(cmd) + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest serviceaccount.ApiListServiceAccountsRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/service-account/service_account.go b/internal/cmd/service-account/service_account.go new file mode 100644 index 00000000..9be58f7a --- /dev/null +++ b/internal/cmd/service-account/service_account.go @@ -0,0 +1,36 @@ +package serviceaccount + +import ( + "stackit/internal/cmd/service-account/create" + "stackit/internal/cmd/service-account/delete" + getjwks "stackit/internal/cmd/service-account/get-jwks" + "stackit/internal/cmd/service-account/key" + "stackit/internal/cmd/service-account/list" + "stackit/internal/cmd/service-account/token" + "stackit/internal/pkg/args" + "stackit/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "service-account", + Short: "Provides functionality for service accounts", + Long: "Provides functionality for service accounts", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd) + return cmd +} + +func addSubcommands(cmd *cobra.Command) { + cmd.AddCommand(list.NewCmd()) + cmd.AddCommand(create.NewCmd()) + cmd.AddCommand(delete.NewCmd()) + cmd.AddCommand(getjwks.NewCmd()) + + cmd.AddCommand(key.NewCmd()) + cmd.AddCommand(token.NewCmd()) +} diff --git a/internal/cmd/service-account/token/create/create.go b/internal/cmd/service-account/token/create/create.go new file mode 100644 index 00000000..0a285ce6 --- /dev/null +++ b/internal/cmd/service-account/token/create/create.go @@ -0,0 +1,133 @@ +package create + +import ( + "context" + "fmt" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/confirm" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/flags" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/services/service-account/client" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/serviceaccount" +) + +const ( + serviceAccountEmailFlag = "email" + ttlDaysFlag = "ttl-days" + + defaultTTLDays = 90 +) + +type inputModel struct { + *globalflags.GlobalFlagModel + + ServiceAccountEmail string + TTLDays *int64 +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Create an access token for a service account", + Long: fmt.Sprintf("%s\n%s\n%s", + "Create an access token for a service account.", + "The access token can be then used for API calls (where enabled).", + "The token is only displayed upon creation, and it will not be recoverable later.", + ), + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create an access token for the service account with email "my-service-account-1234567@sa.stackit.cloud" with a default time to live`, + "$ stackit service-account token create --sa-email my-service-account-1234567@sa.stackit.cloud"), + examples.NewExample( + `Create an access token for the service account with email "my-service-account-1234567@sa.stackit.cloud" with a time to live of 10 days`, + "$ stackit service-account token create --email my-service-account-1234567@sa.stackit.cloud --ttl-days 10"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to create an access token for service account %s?", model.ServiceAccountEmail) + err = confirm.PromptForConfirmation(cmd, prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + token, err := req.Execute() + if err != nil { + return fmt.Errorf("create access token: %w", err) + } + + cmd.Printf("Created access token for service account %s. Token ID: %s\n\n", model.ServiceAccountEmail, *token.Id) + cmd.Printf("Valid until: %s\n", *token.ValidUntil) + cmd.Printf("Token: %s\n", *token.Token) + return nil + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().StringP(serviceAccountEmailFlag, "e", "", "Service account email") + cmd.Flags().Int64(ttlDaysFlag, defaultTTLDays, "How long (in days) the new access token is valid") + + err := flags.MarkFlagsRequired(cmd, serviceAccountEmailFlag) + cobra.CheckErr(err) +} + +func parseInput(cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + email := flags.FlagToStringValue(cmd, serviceAccountEmailFlag) + if email == "" { + return nil, &errors.FlagValidationError{ + Flag: serviceAccountEmailFlag, + Details: "can't be empty", + } + } + + ttlDays := flags.FlagWithDefaultToInt64Value(cmd, ttlDaysFlag) + if ttlDays < 1 { + return nil, &errors.FlagValidationError{ + Flag: serviceAccountEmailFlag, + Details: "time to live should be at least 1 day", + } + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + ServiceAccountEmail: email, + TTLDays: &ttlDays, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *serviceaccount.APIClient) serviceaccount.ApiCreateAccessTokenRequest { + req := apiClient.CreateAccessToken(ctx, model.ProjectId, model.ServiceAccountEmail) + req = req.CreateAccessTokenPayload(serviceaccount.CreateAccessTokenPayload{ + TtlDays: model.TTLDays, + }) + return req +} diff --git a/internal/cmd/service-account/token/create/create_test.go b/internal/cmd/service-account/token/create/create_test.go new file mode 100644 index 00000000..9e76406b --- /dev/null +++ b/internal/cmd/service-account/token/create/create_test.go @@ -0,0 +1,192 @@ +package create + +import ( + "context" + "testing" + + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/serviceaccount" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &serviceaccount.APIClient{} +var testProjectId = uuid.NewString() +var testServiceAccountEmail = "my-service-account-1234567@sa.stackit.cloud" +var testTTLDaysString = "90" +var testTTLDays = int64(90) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + serviceAccountEmailFlag: testServiceAccountEmail, + ttlDaysFlag: testTTLDaysString, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + ServiceAccountEmail: testServiceAccountEmail, + TTLDays: utils.Ptr(testTTLDays), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *serviceaccount.ApiCreateAccessTokenRequest)) serviceaccount.ApiCreateAccessTokenRequest { + request := testClient.CreateAccessToken(testCtx, testProjectId, testServiceAccountEmail) + request = request.CreateAccessTokenPayload(serviceaccount.CreateAccessTokenPayload{ + TtlDays: utils.Ptr(int64(testTTLDays)), + }) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "service account email missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, serviceAccountEmailFlag) + }), + isValid: false, + }, + { + description: "ttl days invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[ttlDaysFlag] = "invalid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + isValid bool + expectedRequest serviceaccount.ApiCreateAccessTokenRequest + }{ + { + description: "base", + model: fixtureInputModel(), + isValid: true, + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/service-account/token/list/list.go b/internal/cmd/service-account/token/list/list.go new file mode 100644 index 00000000..17bb6c93 --- /dev/null +++ b/internal/cmd/service-account/token/list/list.go @@ -0,0 +1,157 @@ +package list + +import ( + "context" + "encoding/json" + "fmt" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/flags" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/services/service-account/client" + "stackit/internal/pkg/tables" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/serviceaccount" +) + +const ( + limitFlag = "limit" + serviceAccountEmailFlag = "email" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + + ServiceAccountEmail string + Limit *int64 +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List access tokens of a service account", + Long: fmt.Sprintf("%s\n%s\n%s", + "List access tokens of a service account.", + "Only the metadata about the access tokens is shown, and not the tokens themselves.", + "Access tokens (including revoked tokens) are returned until they are expired.", + ), + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all access tokens of the service account with email "my-service-account-1234567@sa.stackit.cloud"`, + "$ stackit service-account token list --email my-service-account-1234567@sa.stackit.cloud"), + examples.NewExample( + `List all access tokens of the service account with email "my-service-account-1234567@sa.stackit.cloud" in JSON format`, + "$ stackit service-account token list --email my-service-account-1234567@sa.stackit.cloud --output-format json"), + examples.NewExample( + `List up to 10 access tokens of the service account with email "my-service-account-1234567@sa.stackit.cloud"`, + "$ stackit service-account token list --email my-service-account-1234567@sa.stackit.cloud --limit 10"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("list tokens metadata: %w", err) + } + tokensMetadata := *resp.Items + if len(tokensMetadata) == 0 { + cmd.Printf("No tokens found for service account with email %s\n", model.ServiceAccountEmail) + return nil + } + + // Truncate output + if model.Limit != nil && len(tokensMetadata) > int(*model.Limit) { + tokensMetadata = tokensMetadata[:*model.Limit] + } + + return outputResult(cmd, model.OutputFormat, tokensMetadata) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().StringP(serviceAccountEmailFlag, "e", "", "Service account email") + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") + + err := flags.MarkFlagsRequired(cmd, serviceAccountEmailFlag) + cobra.CheckErr(err) +} + +func parseInput(cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + email := flags.FlagToStringValue(cmd, serviceAccountEmailFlag) + if email == "" { + return nil, &errors.FlagValidationError{ + Flag: serviceAccountEmailFlag, + Details: "can't be empty.", + } + } + + limit := flags.FlagToInt64Pointer(cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + ServiceAccountEmail: email, + Limit: limit, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *serviceaccount.APIClient) serviceaccount.ApiListAccessTokensRequest { + req := apiClient.ListAccessTokens(ctx, model.ProjectId, model.ServiceAccountEmail) + return req +} + +func outputResult(cmd *cobra.Command, outputFormat string, tokensMetadata []serviceaccount.AccessTokenMetadata) error { + switch outputFormat { + case globalflags.JSONOutputFormat: + details, err := json.MarshalIndent(tokensMetadata, "", " ") + if err != nil { + return fmt.Errorf("marshal tokens metadata: %w", err) + } + cmd.Println(string(details)) + + return nil + default: + table := tables.NewTable() + table.SetHeader("ID", "ACTIVE", "CREATED_AT", "VALID_UNTIL") + for i := range tokensMetadata { + t := tokensMetadata[i] + table.AddRow(*t.Id, *t.Active, *t.CreatedAt, *t.ValidUntil) + } + err := table.Display(cmd) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + } +} diff --git a/internal/cmd/service-account/token/list/list_test.go b/internal/cmd/service-account/token/list/list_test.go new file mode 100644 index 00000000..b9ad59b0 --- /dev/null +++ b/internal/cmd/service-account/token/list/list_test.go @@ -0,0 +1,195 @@ +package list + +import ( + "context" + "testing" + + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/serviceaccount" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &serviceaccount.APIClient{} +var testProjectId = uuid.NewString() +var testServiceAccountEmail = "my-service-account-1234567@sa.stackit.cloud" + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + serviceAccountEmailFlag: testServiceAccountEmail, + limitFlag: "10", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + ServiceAccountEmail: testServiceAccountEmail, + Limit: utils.Ptr(int64(10)), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *serviceaccount.ApiListAccessTokensRequest)) serviceaccount.ApiListAccessTokensRequest { + request := testClient.ListAccessTokens(testCtx, testProjectId, testServiceAccountEmail) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "service account email missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, serviceAccountEmailFlag) + }), + isValid: false, + }, + { + description: "limit invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "invalid" + }), + isValid: false, + }, + { + description: "limit invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := &cobra.Command{} + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + configureFlags(cmd) + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest serviceaccount.ApiListAccessTokensRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/service-account/token/revoke/revoke.go b/internal/cmd/service-account/token/revoke/revoke.go new file mode 100644 index 00000000..09c04061 --- /dev/null +++ b/internal/cmd/service-account/token/revoke/revoke.go @@ -0,0 +1,117 @@ +package revoke + +import ( + "context" + "fmt" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/confirm" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/flags" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/services/service-account/client" + "stackit/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/serviceaccount" +) + +const ( + serviceAccountEmailFlag = "email" + tokenIdArg = "TOKEN_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + + ServiceAccountEmail string + TokenId string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("revoke %s", tokenIdArg), + Short: "Revoke an access token of a service account", + Long: fmt.Sprintf("%s\n%s\n%s", + "Revoke an access token of a service account.", + "The access token is instantly revoked, any following calls with the token will be unauthorized.", + "The token metadata is still stored until the expiration time.", + ), + Args: args.SingleArg(tokenIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Revoke an access token with ID "xxx" of the service account with email "my-service-account-1234567@sa.stackit.cloud"`, + "$ stackit service-account token revoke xxx --email my-service-account-1234567@sa.stackit.cloud"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to revoke the access token with ID %s?", model.TokenId) + err = confirm.PromptForConfirmation(cmd, prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + err = req.Execute() + if err != nil { + return fmt.Errorf("revoke access token: %w", err) + } + + cmd.Printf("Revoked access token with ID %s\n", model.TokenId) + return nil + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().StringP(serviceAccountEmailFlag, "e", "", "Service account email") + + err := flags.MarkFlagsRequired(cmd, serviceAccountEmailFlag) + cobra.CheckErr(err) +} + +func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + tokenId := inputArgs[0] + + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + email := flags.FlagToStringValue(cmd, serviceAccountEmailFlag) + if email == "" { + return nil, &errors.FlagValidationError{ + Flag: serviceAccountEmailFlag, + Details: "can't be empty", + } + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + ServiceAccountEmail: email, + TokenId: tokenId, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *serviceaccount.APIClient) serviceaccount.ApiDeleteAccessTokenRequest { + req := apiClient.DeleteAccessToken(ctx, model.ProjectId, model.ServiceAccountEmail, model.TokenId) + return req +} diff --git a/internal/cmd/service-account/token/revoke/revoke_test.go b/internal/cmd/service-account/token/revoke/revoke_test.go new file mode 100644 index 00000000..47012598 --- /dev/null +++ b/internal/cmd/service-account/token/revoke/revoke_test.go @@ -0,0 +1,228 @@ +package revoke + +import ( + "context" + "testing" + + "stackit/internal/pkg/globalflags" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/serviceaccount" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &serviceaccount.APIClient{} +var testProjectId = uuid.NewString() +var testServiceAccountEmail = "my-service-account-1234567@sa.stackit.cloud" +var testTokenId = uuid.NewString() + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testTokenId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + serviceAccountEmailFlag: testServiceAccountEmail, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + ServiceAccountEmail: testServiceAccountEmail, + TokenId: testTokenId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *serviceaccount.ApiDeleteAccessTokenRequest)) serviceaccount.ApiDeleteAccessTokenRequest { + request := testClient.DeleteAccessToken(testCtx, testProjectId, testServiceAccountEmail, testTokenId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "service account email missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, serviceAccountEmailFlag) + }), + isValid: false, + }, + { + description: "token id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "token id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + isValid bool + expectedRequest serviceaccount.ApiDeleteAccessTokenRequest + }{ + { + description: "base", + model: fixtureInputModel(), + isValid: true, + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/service-account/token/token.go b/internal/cmd/service-account/token/token.go new file mode 100644 index 00000000..b27310ef --- /dev/null +++ b/internal/cmd/service-account/token/token.go @@ -0,0 +1,29 @@ +package token + +import ( + "stackit/internal/cmd/service-account/token/create" + "stackit/internal/cmd/service-account/token/list" + "stackit/internal/cmd/service-account/token/revoke" + "stackit/internal/pkg/args" + "stackit/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "token", + Short: "Provides functionality regarding service account tokens", + Long: "Provides functionality regarding service account tokens", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd) + return cmd +} + +func addSubcommands(cmd *cobra.Command) { + cmd.AddCommand(create.NewCmd()) + cmd.AddCommand(list.NewCmd()) + cmd.AddCommand(revoke.NewCmd()) +} diff --git a/internal/cmd/ske/cluster/cluster.go b/internal/cmd/ske/cluster/cluster.go new file mode 100644 index 00000000..52465944 --- /dev/null +++ b/internal/cmd/ske/cluster/cluster.go @@ -0,0 +1,35 @@ +package cluster + +import ( + "stackit/internal/cmd/ske/cluster/create" + "stackit/internal/cmd/ske/cluster/delete" + "stackit/internal/cmd/ske/cluster/describe" + generatepayload "stackit/internal/cmd/ske/cluster/generate-payload" + "stackit/internal/cmd/ske/cluster/list" + "stackit/internal/cmd/ske/cluster/update" + "stackit/internal/pkg/args" + "stackit/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "cluster", + Short: "Provides functionality for SKE cluster", + Long: "Provides functionality for STACKIT Kubernetes Engine (SKE) cluster", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd) + return cmd +} + +func addSubcommands(cmd *cobra.Command) { + cmd.AddCommand(generatepayload.NewCmd()) + cmd.AddCommand(create.NewCmd()) + cmd.AddCommand(delete.NewCmd()) + cmd.AddCommand(describe.NewCmd()) + cmd.AddCommand(list.NewCmd()) + cmd.AddCommand(update.NewCmd()) +} diff --git a/internal/cmd/ske/cluster/create/create.go b/internal/cmd/ske/cluster/create/create.go new file mode 100644 index 00000000..e9d7b4a1 --- /dev/null +++ b/internal/cmd/ske/cluster/create/create.go @@ -0,0 +1,180 @@ +package create + +import ( + "context" + "encoding/json" + "fmt" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/confirm" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/flags" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/projectname" + "stackit/internal/pkg/services/ske/client" + skeUtils "stackit/internal/pkg/services/ske/utils" + "stackit/internal/pkg/spinner" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/ske" + "github.com/stackitcloud/stackit-sdk-go/services/ske/wait" +) + +const ( + clusterNameArg = "CLUSTER_NAME" + + payloadFlag = "payload" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ClusterName string + Payload *ske.CreateOrUpdateClusterPayload +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("create %s", clusterNameArg), + Short: "Creates an SKE cluster", + Long: fmt.Sprintf("%s\n%s\n%s", + "Creates a STACKIT Kubernetes Engine (SKE) cluster.", + "The payload can be provided as a JSON string or a file path prefixed with \"@\".", + "See https://docs.api.stackit.cloud/documentation/ske/version/v1#tag/Cluster/operation/SkeService_CreateOrUpdateCluster for information regarding the payload structure.", + ), + Args: args.SingleArg(clusterNameArg, nil), + Example: examples.Build( + examples.NewExample( + `Create an SKE cluster using default configuration`, + "$ stackit ske cluster create my-cluster"), + examples.NewExample( + `Create an SKE cluster using an API payload sourced from the file "./payload.json"`, + "$ stackit ske cluster create my-cluster --payload @./payload.json"), + examples.NewExample( + `Create an SKE cluster using an API payload provided as a JSON string`, + `$ stackit ske cluster create my-cluster --payload "{...}"`), + examples.NewExample( + `Generate a payload with default values, and adapt it with custom values for the different configuration options`, + `$ stackit ske cluster generate-payload > ./payload.json`, + ``, + `$ stackit ske cluster create my-cluster --payload @./payload.json`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, cmd) + if err != nil { + projectLabel = model.ProjectId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to create a cluster for project %s?", projectLabel) + err = confirm.PromptForConfirmation(cmd, prompt) + if err != nil { + return err + } + } + + // Check if SKE is enabled for this project + enabled, err := skeUtils.ProjectEnabled(ctx, apiClient, model.ProjectId) + if err != nil { + return err + } + if !enabled { + return fmt.Errorf("SKE isn't enabled for this project, please run 'stackit ske enable'") + } + + // Check if cluster exists + exists, err := skeUtils.ClusterExists(ctx, apiClient, model.ProjectId, model.ClusterName) + if err != nil { + return err + } + if exists { + return fmt.Errorf("cluster with name %s already exists", model.ClusterName) + } + + // Fill in default payload, if needed + if model.Payload == nil { + defaultPayload, err := skeUtils.GetDefaultPayload(ctx, apiClient) + if err != nil { + return fmt.Errorf("get default payload: %w", err) + } + model.Payload = defaultPayload + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create SKE cluster: %w", err) + } + name := *resp.Name + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(cmd) + s.Start("Creating cluster") + _, err = wait.CreateOrUpdateClusterWaitHandler(ctx, apiClient, model.ProjectId, name).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for SKE cluster creation: %w", err) + } + s.Stop() + } + + operationState := "Created" + if model.Async { + operationState = "Triggered creation of" + } + cmd.Printf("%s cluster for project %s. Cluster name: %s\n", operationState, projectLabel, name) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.ReadFromFileFlag(), payloadFlag, `Request payload (JSON). Can be a string or a file path, if prefixed with "@" (example: @./payload.json). If unset, will use a default payload (you can check it by running "stackit ske cluster generate-payload")`) +} + +func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + clusterName := inputArgs[0] + + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + payloadValue := flags.FlagToStringPointer(cmd, payloadFlag) + var payload *ske.CreateOrUpdateClusterPayload + if payloadValue != nil { + payload = &ske.CreateOrUpdateClusterPayload{} + err := json.Unmarshal([]byte(*payloadValue), payload) + if err != nil { + return nil, fmt.Errorf("encode payload: %w", err) + } + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + ClusterName: clusterName, + Payload: payload, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *ske.APIClient) ske.ApiCreateOrUpdateClusterRequest { + req := apiClient.CreateOrUpdateCluster(ctx, model.ProjectId, model.ClusterName) + + req = req.CreateOrUpdateClusterPayload(*model.Payload) + return req +} diff --git a/internal/cmd/ske/cluster/create/create_test.go b/internal/cmd/ske/cluster/create/create_test.go new file mode 100644 index 00000000..e42a107f --- /dev/null +++ b/internal/cmd/ske/cluster/create/create_test.go @@ -0,0 +1,313 @@ +package create + +import ( + "context" + "testing" + + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/ske" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &ske.APIClient{} +var testProjectId = uuid.NewString() +var testClusterName = "cluster" + +var testPayload = &ske.CreateOrUpdateClusterPayload{ + Kubernetes: &ske.Kubernetes{ + Version: utils.Ptr("1.25.15"), + }, + Nodepools: &[]ske.Nodepool{ + { + Name: utils.Ptr("np-name"), + Machine: &ske.Machine{ + Image: &ske.Image{ + Name: utils.Ptr("flatcar"), + Version: utils.Ptr("3602.2.1"), + }, + Type: utils.Ptr("b1.2"), + }, + Minimum: utils.Ptr(int64(1)), + Maximum: utils.Ptr(int64(2)), + MaxSurge: utils.Ptr(int64(1)), + Volume: &ske.Volume{ + Type: utils.Ptr("storage_premium_perf0"), + Size: utils.Ptr(int64(40)), + }, + AvailabilityZones: &[]string{"eu01-3"}, + Cri: &ske.CRI{Name: utils.Ptr("cri")}, + }, + }, + Extensions: &ske.Extension{ + Acl: &ske.ACL{ + Enabled: utils.Ptr(true), + AllowedCidrs: &[]string{"0.0.0.0/0"}, + }, + }, + Maintenance: &ske.Maintenance{ + AutoUpdate: &ske.MaintenanceAutoUpdate{ + KubernetesVersion: utils.Ptr(true), + MachineImageVersion: utils.Ptr(true), + }, + TimeWindow: &ske.TimeWindow{ + End: utils.Ptr("0000-01-01T05:00:00+02:00"), + Start: utils.Ptr("0000-01-01T03:00:00+02:00"), + }, + }, +} + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testClusterName, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + payloadFlag: `{ + "name": "cli-jp", + "kubernetes": { + "version": "1.25.15" + }, + "nodepools": [ + { + "name": "np-name", + "machine": { + "image": { + "name": "flatcar", + "version": "3602.2.1" + }, + "type": "b1.2" + }, + "minimum": 1, + "maximum": 2, + "maxSurge": 1, + "volume": { "type": "storage_premium_perf0", "size": 40 }, + "cri": { "name": "cri" }, + "availabilityZones": ["eu01-3"] + } + ], + "extensions": { "acl": { "enabled": true, "allowedCidrs": ["0.0.0.0/0"] } }, + "maintenance": { + "autoUpdate": { + "kubernetesVersion": true, + "machineImageVersion": true + }, + "timeWindow": { + "end": "0000-01-01T05:00:00+02:00", + "start": "0000-01-01T03:00:00+02:00" + } + } + }`, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + ClusterName: testClusterName, + Payload: testPayload, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *ske.ApiCreateOrUpdateClusterRequest)) ske.ApiCreateOrUpdateClusterRequest { + request := testClient.CreateOrUpdateCluster(testCtx, testProjectId, fixtureInputModel().ClusterName) + request = request.CreateOrUpdateClusterPayload(*testPayload) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "default config", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, payloadFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Payload = nil + }), + }, + { + description: "invalid json", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[payloadFlag] = "not json" + }), + isValid: false, + expectedModel: fixtureInputModel(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + err = cmd.ValidateFlagGroups() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest ske.ApiCreateOrUpdateClusterRequest + isValid bool + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/ske/cluster/delete/delete.go b/internal/cmd/ske/cluster/delete/delete.go new file mode 100644 index 00000000..16190bb3 --- /dev/null +++ b/internal/cmd/ske/cluster/delete/delete.go @@ -0,0 +1,107 @@ +package delete + +import ( + "context" + "fmt" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/confirm" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/services/ske/client" + "stackit/internal/pkg/spinner" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/ske" + "github.com/stackitcloud/stackit-sdk-go/services/ske/wait" +) + +const ( + clusterNameArg = "CLUSTER_NAME" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ClusterName string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", clusterNameArg), + Short: "Delete a SKE cluster", + Long: "Delete a STACKIT Kubernetes Engine (SKE) cluster", + Args: args.SingleArg(clusterNameArg, nil), + Example: examples.Build( + examples.NewExample( + `Delete an SKE cluster with name "my-cluster"`, + "$ stackit ske cluster delete my-cluster"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to delete cluster %s? (This cannot be undone)", model.ClusterName) + err = confirm.PromptForConfirmation(cmd, prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + _, err = req.Execute() + if err != nil { + return fmt.Errorf("delete SKE cluster: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(cmd) + s.Start("Deleting cluster") + _, err = wait.DeleteClusterWaitHandler(ctx, apiClient, model.ProjectId, model.ClusterName).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for SKE cluster deletion: %w", err) + } + s.Stop() + } + + operationState := "Deleted" + if model.Async { + operationState = "Triggered deletion of" + } + cmd.Printf("%s cluster %s\n", operationState, model.ClusterName) + return nil + }, + } + return cmd +} + +func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + clusterName := inputArgs[0] + + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + ClusterName: clusterName, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *ske.APIClient) ske.ApiDeleteClusterRequest { + req := apiClient.DeleteCluster(ctx, model.ProjectId, model.ClusterName) + return req +} diff --git a/internal/cmd/ske/cluster/delete/delete_test.go b/internal/cmd/ske/cluster/delete/delete_test.go new file mode 100644 index 00000000..d55dc4d8 --- /dev/null +++ b/internal/cmd/ske/cluster/delete/delete_test.go @@ -0,0 +1,205 @@ +package delete + +import ( + "context" + "testing" + + "stackit/internal/pkg/globalflags" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/ske" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &ske.APIClient{} +var testProjectId = uuid.NewString() +var testClusterName = "cluster" + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testClusterName, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + ClusterName: testClusterName, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *ske.ApiDeleteClusterRequest)) ske.ApiDeleteClusterRequest { + request := testClient.DeleteCluster(testCtx, testProjectId, testClusterName) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + isValid bool + expectedRequest ske.ApiDeleteClusterRequest + }{ + { + description: "base", + model: fixtureInputModel(), + isValid: true, + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/ske/cluster/describe/describe.go b/internal/cmd/ske/cluster/describe/describe.go new file mode 100644 index 00000000..91830c16 --- /dev/null +++ b/internal/cmd/ske/cluster/describe/describe.go @@ -0,0 +1,118 @@ +package describe + +import ( + "context" + "encoding/json" + "fmt" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/services/ske/client" + "stackit/internal/pkg/tables" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/ske" +) + +const ( + clusterNameArg = "CLUSTER_NAME" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ClusterName string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", clusterNameArg), + Short: "Get details of a SKE cluster", + Long: "Get details of a STACKIT Kubernetes Engine (SKE) cluster", + Args: args.SingleArg(clusterNameArg, nil), + Example: examples.Build( + examples.NewExample( + `Get details of an SKE cluster with name "my-cluster"`, + "$ stackit ske cluster describe my-cluster"), + examples.NewExample( + `Get details of an SKE cluster with name "my-cluster" in a table format`, + "$ stackit ske cluster describe my-cluster --output-format pretty"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd, args) + if err != nil { + return err + } + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("read SKE cluster: %w", err) + } + + return outputResult(cmd, model.OutputFormat, resp) + }, + } + return cmd +} + +func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + clusterName := inputArgs[0] + + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + ClusterName: clusterName, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *ske.APIClient) ske.ApiGetClusterRequest { + req := apiClient.GetCluster(ctx, model.ProjectId, model.ClusterName) + return req +} + +func outputResult(cmd *cobra.Command, outputFormat string, cluster *ske.Cluster) error { + switch outputFormat { + case globalflags.PrettyOutputFormat: + + acl := []string{} + if cluster.Extensions != nil && cluster.Extensions.Acl != nil { + acl = *cluster.Extensions.Acl.AllowedCidrs + } + + table := tables.NewTable() + table.AddRow("NAME", *cluster.Name) + table.AddSeparator() + table.AddRow("STATE", *cluster.Status.Aggregated) + table.AddSeparator() + table.AddRow("VERSION", *cluster.Kubernetes.Version) + table.AddSeparator() + table.AddRow("ACL", acl) + err := table.Display(cmd) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + default: + details, err := json.MarshalIndent(cluster, "", " ") + if err != nil { + return fmt.Errorf("marshal SKE cluster: %w", err) + } + cmd.Println(string(details)) + + return nil + } +} diff --git a/internal/cmd/ske/cluster/describe/describe_test.go b/internal/cmd/ske/cluster/describe/describe_test.go new file mode 100644 index 00000000..ae1dc304 --- /dev/null +++ b/internal/cmd/ske/cluster/describe/describe_test.go @@ -0,0 +1,205 @@ +package describe + +import ( + "context" + "testing" + + "stackit/internal/pkg/globalflags" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/ske" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &ske.APIClient{} +var testProjectId = uuid.NewString() +var testClusterName = "cluster" + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testClusterName, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + ClusterName: testClusterName, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *ske.ApiGetClusterRequest)) ske.ApiGetClusterRequest { + request := testClient.GetCluster(testCtx, testProjectId, testClusterName) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + isValid bool + expectedRequest ske.ApiGetClusterRequest + }{ + { + description: "base", + model: fixtureInputModel(), + isValid: true, + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/ske/cluster/generate-payload/generate_payload.go b/internal/cmd/ske/cluster/generate-payload/generate_payload.go new file mode 100644 index 00000000..18ac87ff --- /dev/null +++ b/internal/cmd/ske/cluster/generate-payload/generate_payload.go @@ -0,0 +1,124 @@ +package generatepayload + +import ( + "context" + "encoding/json" + "fmt" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/flags" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/services/ske/client" + skeUtils "stackit/internal/pkg/services/ske/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/ske" +) + +const ( + clusterNameFlag = "cluster-name" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ClusterName *string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "generate-payload", + Short: "Generates a payload to create/update SKE clusters", + Long: fmt.Sprintf("%s\n%s", + "Generates a JSON payload with values to be used as --payload input for cluster creation or update.", + "See https://docs.api.stackit.cloud/documentation/ske/version/v1#tag/Cluster/operation/SkeService_CreateOrUpdateCluster for information regarding the payload structure.", + ), + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Generate a payload with default values, and adapt it with custom values for the different configuration options`, + `$ stackit ske cluster generate-payload > ./payload.json`, + ``, + `$ stackit ske cluster create my-cluster --payload @./payload.json`), + examples.NewExample( + `Generate a payload with values of a cluster, and adapt it with custom values for the different configuration options`, + `$ stackit ske cluster generate-payload --cluster-name my-cluster > ./payload.json`, + ``, + `$ stackit ske cluster update my-cluster --payload @./payload.json`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + var payload *ske.CreateOrUpdateClusterPayload + if model.ClusterName == nil { + payload, err = skeUtils.GetDefaultPayload(ctx, apiClient) + if err != nil { + return err + } + } else { + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("read SKE cluster: %w", err) + } + payload = &ske.CreateOrUpdateClusterPayload{ + Extensions: resp.Extensions, + Hibernation: resp.Hibernation, + Kubernetes: resp.Kubernetes, + Maintenance: resp.Maintenance, + Nodepools: resp.Nodepools, + Status: resp.Status, + } + } + + return outputResult(cmd, payload) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().StringP(clusterNameFlag, "n", "", "If set, generates the payload with the current state of the given cluster. If unset, generates the payload with default values") +} + +func parseInput(cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(cmd) + + clusterName := flags.FlagToStringPointer(cmd, clusterNameFlag) + // If clusterName is provided, projectId is needed as well + if clusterName != nil && globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + ClusterName: clusterName, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *ske.APIClient) ske.ApiGetClusterRequest { + req := apiClient.GetCluster(ctx, model.ProjectId, *model.ClusterName) + return req +} + +func outputResult(cmd *cobra.Command, payload *ske.CreateOrUpdateClusterPayload) error { + payloadBytes, err := json.MarshalIndent(*payload, "", " ") + if err != nil { + return fmt.Errorf("marshal payload: %w", err) + } + cmd.Println(string(payloadBytes)) + + return nil +} diff --git a/internal/cmd/ske/cluster/generate-payload/generate_payload_test.go b/internal/cmd/ske/cluster/generate-payload/generate_payload_test.go new file mode 100644 index 00000000..cec0d5a5 --- /dev/null +++ b/internal/cmd/ske/cluster/generate-payload/generate_payload_test.go @@ -0,0 +1,190 @@ +package generatepayload + +import ( + "context" + "testing" + + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/ske" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &ske.APIClient{} +var testProjectId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + clusterNameFlag: "example-name", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + ClusterName: utils.Ptr("example-name"), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *ske.ApiGetClusterRequest)) ske.ApiGetClusterRequest { + request := testClient.GetCluster(testCtx, testProjectId, "example-name") + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: true, + expectedModel: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{}, + }, + }, + { + description: "name missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, clusterNameFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.ClusterName = nil + }), + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + err = cmd.ValidateFlagGroups() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest ske.ApiGetClusterRequest + isValid bool + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/ske/cluster/list/list.go b/internal/cmd/ske/cluster/list/list.go new file mode 100644 index 00000000..f3bc2d6d --- /dev/null +++ b/internal/cmd/ske/cluster/list/list.go @@ -0,0 +1,156 @@ +package list + +import ( + "context" + "encoding/json" + "fmt" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/flags" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/projectname" + "stackit/internal/pkg/services/ske/client" + skeUtils "stackit/internal/pkg/services/ske/utils" + "stackit/internal/pkg/tables" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/ske" +) + +const ( + limitFlag = "limit" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Limit *int64 +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List all SKE clusters", + Long: "List all STACKIT Kubernetes Engine (SKE) clusters", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all SKE clusters`, + "$ stackit ske cluster list"), + examples.NewExample( + `List all SKE clusters in JSON format`, + "$ stackit ske cluster list --output-format json"), + examples.NewExample( + `List up to 10 SKE clusters`, + "$ stackit ske cluster list --limit 10"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + // Check if SKE is enabled for this project + enabled, err := skeUtils.ProjectEnabled(ctx, apiClient, model.ProjectId) + if err != nil { + return err + } + if !enabled { + return fmt.Errorf("SKE isn't enabled for this project, please run 'stackit ske enable'") + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get SKE clusters: %w", err) + } + clusters := *resp.Items + if len(clusters) == 0 { + projectLabel, err := projectname.GetProjectName(ctx, cmd) + if err != nil { + projectLabel = model.ProjectId + } + cmd.Printf("No clusters found for project %s\n", projectLabel) + return nil + } + + // Truncate output + if model.Limit != nil && len(clusters) > int(*model.Limit) { + clusters = clusters[:*model.Limit] + } + + return outputResult(cmd, model.OutputFormat, clusters) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") +} + +func parseInput(cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + limit := flags.FlagToInt64Pointer(cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + Limit: flags.FlagToInt64Pointer(cmd, limitFlag), + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *ske.APIClient) ske.ApiListClustersRequest { + req := apiClient.ListClusters(ctx, model.ProjectId) + return req +} + +func outputResult(cmd *cobra.Command, outputFormat string, clusters []ske.Cluster) error { + switch outputFormat { + case globalflags.JSONOutputFormat: + details, err := json.MarshalIndent(clusters, "", " ") + if err != nil { + return fmt.Errorf("marshal SKE cluster list: %w", err) + } + cmd.Println(string(details)) + + return nil + default: + table := tables.NewTable() + table.SetHeader("NAME", "STATE", "VERSION", "POOLS", "MONITORING") + for i := range clusters { + c := clusters[i] + monitoring := "Disabled" + if c.Extensions != nil && c.Extensions.Argus != nil && *c.Extensions.Argus.Enabled { + monitoring = "Enabled" + } + table.AddRow(*c.Name, *c.Status.Aggregated, *c.Kubernetes.Version, len(*c.Nodepools), monitoring) + } + err := table.Display(cmd) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + } +} diff --git a/internal/cmd/ske/cluster/list/list_test.go b/internal/cmd/ske/cluster/list/list_test.go new file mode 100644 index 00000000..48660365 --- /dev/null +++ b/internal/cmd/ske/cluster/list/list_test.go @@ -0,0 +1,185 @@ +package list + +import ( + "context" + "testing" + + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/ske" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &ske.APIClient{} +var testProjectId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + limitFlag: "10", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + Limit: utils.Ptr(int64(10)), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *ske.ApiListClustersRequest)) ske.ApiListClustersRequest { + request := testClient.ListClusters(testCtx, testProjectId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "limit invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "invalid" + }), + isValid: false, + }, + { + description: "limit invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := &cobra.Command{} + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + configureFlags(cmd) + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest ske.ApiListClustersRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/ske/cluster/update/update.go b/internal/cmd/ske/cluster/update/update.go new file mode 100644 index 00000000..4421ccd1 --- /dev/null +++ b/internal/cmd/ske/cluster/update/update.go @@ -0,0 +1,144 @@ +package update + +import ( + "context" + "encoding/json" + "fmt" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/flags" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/services/ske/client" + skeUtils "stackit/internal/pkg/services/ske/utils" + "stackit/internal/pkg/spinner" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/ske" + "github.com/stackitcloud/stackit-sdk-go/services/ske/wait" +) + +const ( + clusterNameArg = "CLUSTER_NAME" + + payloadFlag = "payload" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ClusterName string + Payload ske.CreateOrUpdateClusterPayload +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("update %s", clusterNameArg), + Short: "Updates an SKE cluster", + Long: fmt.Sprintf("%s\n%s\n%s", + "Updates a STACKIT Kubernetes Engine (SKE) cluster.", + "The payload can be provided as a JSON string or a file path prefixed with \"@\".", + "See https://docs.api.stackit.cloud/documentation/ske/version/v1#tag/Cluster/operation/SkeService_CreateOrUpdateCluster for information regarding the payload structure.", + ), + Args: args.SingleArg(clusterNameArg, nil), + Example: examples.Build( + examples.NewExample( + `Update an SKE cluster using an API payload sourced from the file "./payload.json"`, + "$ stackit ske cluster update my-cluster --payload @./payload.json"), + examples.NewExample( + `Update an SKE cluster using an API payload provided as a JSON string`, + `$ stackit ske cluster update my-cluster --payload "{...}"`), + examples.NewExample( + `Generate a payload with the current values of a cluster, and adapt it with custom values for the different configuration options`, + `$ stackit ske cluster generate-payload --cluster-name my-cluster > ./payload.json`, + ``, + `$ stackit ske cluster update my-cluster --payload @./payload.json`), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + // Check if cluster exists + exists, err := skeUtils.ClusterExists(ctx, apiClient, model.ProjectId, model.ClusterName) + if err != nil { + return err + } + if !exists { + return fmt.Errorf("cluster with name %s does not exist", model.ClusterName) + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("update SKE cluster: %w", err) + } + name := *resp.Name + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(cmd) + s.Start("Updating cluster") + _, err = wait.CreateOrUpdateClusterWaitHandler(ctx, apiClient, model.ProjectId, name).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for SKE cluster update: %w", err) + } + s.Stop() + } + + operationState := "Updated" + if model.Async { + operationState = "Triggered update of" + } + cmd.Printf("%s cluster %s\n", operationState, model.ClusterName) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.ReadFromFileFlag(), payloadFlag, `Request payload (JSON). Can be a string or a file path, if prefixed with "@". Example: @./payload.json`) + + err := flags.MarkFlagsRequired(cmd, payloadFlag) + cobra.CheckErr(err) +} + +func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + clusterName := inputArgs[0] + + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + payloadString := flags.FlagToStringValue(cmd, payloadFlag) + var payload ske.CreateOrUpdateClusterPayload + err := json.Unmarshal([]byte(payloadString), &payload) + if err != nil { + return nil, fmt.Errorf("encode payload: %w", err) + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + ClusterName: clusterName, + Payload: payload, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *ske.APIClient) ske.ApiCreateOrUpdateClusterRequest { + req := apiClient.CreateOrUpdateCluster(ctx, model.ProjectId, model.ClusterName) + + req = req.CreateOrUpdateClusterPayload(model.Payload) + return req +} diff --git a/internal/cmd/ske/cluster/update/update_test.go b/internal/cmd/ske/cluster/update/update_test.go new file mode 100644 index 00000000..411d9f62 --- /dev/null +++ b/internal/cmd/ske/cluster/update/update_test.go @@ -0,0 +1,293 @@ +package update + +import ( + "context" + "testing" + + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/ske" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &ske.APIClient{} +var testProjectId = uuid.NewString() +var testClusterName = "cluster" + +var testPayload = ske.CreateOrUpdateClusterPayload{ + Kubernetes: &ske.Kubernetes{ + Version: utils.Ptr("1.25.15"), + }, + Nodepools: &[]ske.Nodepool{ + { + Name: utils.Ptr("np-name"), + Machine: &ske.Machine{ + Image: &ske.Image{ + Name: utils.Ptr("flatcar"), + Version: utils.Ptr("3602.2.1"), + }, + Type: utils.Ptr("b1.2"), + }, + Minimum: utils.Ptr(int64(1)), + Maximum: utils.Ptr(int64(2)), + MaxSurge: utils.Ptr(int64(1)), + Volume: &ske.Volume{ + Type: utils.Ptr("storage_premium_perf0"), + Size: utils.Ptr(int64(40)), + }, + AvailabilityZones: &[]string{"eu01-3"}, + Cri: &ske.CRI{Name: utils.Ptr("cri")}, + }, + }, + Extensions: &ske.Extension{ + Acl: &ske.ACL{ + Enabled: utils.Ptr(true), + AllowedCidrs: &[]string{"0.0.0.0/0"}, + }, + }, + Maintenance: &ske.Maintenance{ + AutoUpdate: &ske.MaintenanceAutoUpdate{ + KubernetesVersion: utils.Ptr(true), + MachineImageVersion: utils.Ptr(true), + }, + TimeWindow: &ske.TimeWindow{ + End: utils.Ptr("0000-01-01T05:00:00+02:00"), + Start: utils.Ptr("0000-01-01T03:00:00+02:00"), + }, + }, +} + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testClusterName, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + payloadFlag: `{ + "name": "cli-jp", + "kubernetes": { + "version": "1.25.15" + }, + "nodepools": [ + { + "name": "np-name", + "machine": { + "image": { + "name": "flatcar", + "version": "3602.2.1" + }, + "type": "b1.2" + }, + "minimum": 1, + "maximum": 2, + "maxSurge": 1, + "volume": { "type": "storage_premium_perf0", "size": 40 }, + "cri": { "name": "cri" }, + "availabilityZones": ["eu01-3"] + } + ], + "extensions": { "acl": { "enabled": true, "allowedCidrs": ["0.0.0.0/0"] } }, + "maintenance": { + "autoUpdate": { + "kubernetesVersion": true, + "machineImageVersion": true + }, + "timeWindow": { + "end": "0000-01-01T05:00:00+02:00", + "start": "0000-01-01T03:00:00+02:00" + } + } + }`, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + ClusterName: testClusterName, + Payload: testPayload, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *ske.ApiCreateOrUpdateClusterRequest)) ske.ApiCreateOrUpdateClusterRequest { + request := testClient.CreateOrUpdateCluster(testCtx, testProjectId, fixtureInputModel().ClusterName) + request = request.CreateOrUpdateClusterPayload(testPayload) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "invalid json", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[payloadFlag] = "not json" + }), + isValid: false, + expectedModel: fixtureInputModel(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest ske.ApiCreateOrUpdateClusterRequest + isValid bool + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/ske/credentials/credentials.go b/internal/cmd/ske/credentials/credentials.go new file mode 100644 index 00000000..3a2f72aa --- /dev/null +++ b/internal/cmd/ske/credentials/credentials.go @@ -0,0 +1,27 @@ +package credentials + +import ( + "stackit/internal/cmd/ske/credentials/describe" + "stackit/internal/cmd/ske/credentials/rotate" + "stackit/internal/pkg/args" + "stackit/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "credentials", + Short: "Provides functionality for SKE credentials", + Long: "Provides functionality for STACKIT Kubernetes Engine (SKE) credentials", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd) + return cmd +} + +func addSubcommands(cmd *cobra.Command) { + cmd.AddCommand(describe.NewCmd()) + cmd.AddCommand(rotate.NewCmd()) +} diff --git a/internal/cmd/ske/credentials/describe/describe.go b/internal/cmd/ske/credentials/describe/describe.go new file mode 100644 index 00000000..1e5547fd --- /dev/null +++ b/internal/cmd/ske/credentials/describe/describe.go @@ -0,0 +1,119 @@ +package describe + +import ( + "context" + "encoding/json" + "fmt" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/services/ske/client" + skeUtils "stackit/internal/pkg/services/ske/utils" + "stackit/internal/pkg/tables" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/ske" +) + +const ( + clusterNameArg = "CLUSTER_NAME" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ClusterName string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", clusterNameArg), + Short: "Get details of the credentials associated to a SKE cluster", + Long: "Get details of the credentials associated to a STACKIT Kubernetes Engine (SKE) cluster", + Args: args.SingleArg(clusterNameArg, nil), + Example: examples.Build( + examples.NewExample( + `Get details of the credentials associated to the SKE cluster with name "my-cluster"`, + "$ stackit ske credentials describe my-cluster"), + examples.NewExample( + `Get details of the credentials associated to the SKE cluster with name "my-cluster" in a table format`, + "$ stackit ske credentials describe my-cluster --output-format pretty"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + // Check if SKE is enabled for this project + enabled, err := skeUtils.ProjectEnabled(ctx, apiClient, model.ProjectId) + if err != nil { + return err + } + if !enabled { + return fmt.Errorf("SKE isn't enabled for this project, please run 'stackit ske enable'") + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get SKE credentials: %w", err) + } + + return outputResult(cmd, model.OutputFormat, resp) + }, + } + return cmd +} + +func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + clusterName := inputArgs[0] + + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + ClusterName: clusterName, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *ske.APIClient) ske.ApiGetCredentialsRequest { + req := apiClient.GetCredentials(ctx, model.ProjectId, model.ClusterName) + return req +} + +func outputResult(cmd *cobra.Command, outputFormat string, credential *ske.Credentials) error { + switch outputFormat { + case globalflags.PrettyOutputFormat: + table := tables.NewTable() + table.AddRow("SERVER", *credential.Server) + table.AddSeparator() + table.AddRow("TOKEN", *credential.Token) + err := table.Display(cmd) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + default: + details, err := json.MarshalIndent(credential, "", " ") + if err != nil { + return fmt.Errorf("marshal SKE credentials: %w", err) + } + cmd.Println(string(details)) + + return nil + } +} diff --git a/internal/cmd/ske/credentials/describe/describe_test.go b/internal/cmd/ske/credentials/describe/describe_test.go new file mode 100644 index 00000000..c54943d7 --- /dev/null +++ b/internal/cmd/ske/credentials/describe/describe_test.go @@ -0,0 +1,203 @@ +package describe + +import ( + "context" + "testing" + + "stackit/internal/pkg/globalflags" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/ske" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &ske.APIClient{} +var testProjectId = uuid.NewString() +var testClusterName = "cluster" + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testClusterName, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + ClusterName: testClusterName, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *ske.ApiGetCredentialsRequest)) ske.ApiGetCredentialsRequest { + request := testClient.GetCredentials(testCtx, testProjectId, testClusterName) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest ske.ApiGetCredentialsRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/ske/credentials/rotate/rotate.go b/internal/cmd/ske/credentials/rotate/rotate.go new file mode 100644 index 00000000..c4424167 --- /dev/null +++ b/internal/cmd/ske/credentials/rotate/rotate.go @@ -0,0 +1,107 @@ +package rotate + +import ( + "context" + "fmt" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/confirm" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/services/ske/client" + "stackit/internal/pkg/spinner" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/ske" + "github.com/stackitcloud/stackit-sdk-go/services/ske/wait" +) + +const ( + clusterNameArg = "CLUSTER_NAME" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + ClusterName string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("rotate %s", clusterNameArg), + Short: "Rotate credentials associated to a SKE cluster", + Long: "Rotate credentials associated to a STACKIT Kubernetes Engine (SKE) cluster. The old credentials will be invalid after the operation", + Args: args.SingleArg(clusterNameArg, nil), + Example: examples.Build( + examples.NewExample( + `Rotate credentials associated to the SKE cluster with name "my-cluster"`, + "$ stackit ske credentials rotate my-cluster"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to rotate credentials for SKE cluster %s? (The old credentials will be invalid after this operation)", model.ClusterName) + err = confirm.PromptForConfirmation(cmd, prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + _, err = req.Execute() + if err != nil { + return fmt.Errorf("rotate SKE credentials: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(cmd) + s.Start("Rotating credentials") + _, err = wait.RotateCredentialsWaitHandler(ctx, apiClient, model.ProjectId, model.ClusterName).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for SKE credentials rotation: %w", err) + } + s.Stop() + } + + operationState := "Rotated" + if model.Async { + operationState = "Triggered rotation of" + } + cmd.Printf("%s credentials for cluster %s\n", operationState, model.ClusterName) + return nil + }, + } + return cmd +} + +func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + clusterName := inputArgs[0] + + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + ClusterName: clusterName, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *ske.APIClient) ske.ApiTriggerRotateCredentialsRequest { + req := apiClient.TriggerRotateCredentials(ctx, model.ProjectId, model.ClusterName) + return req +} diff --git a/internal/cmd/ske/credentials/rotate/rotate_test.go b/internal/cmd/ske/credentials/rotate/rotate_test.go new file mode 100644 index 00000000..1488770b --- /dev/null +++ b/internal/cmd/ske/credentials/rotate/rotate_test.go @@ -0,0 +1,203 @@ +package rotate + +import ( + "context" + "testing" + + "stackit/internal/pkg/globalflags" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/ske" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &ske.APIClient{} +var testProjectId = uuid.NewString() +var testClusterName = "cluster" + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testClusterName, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + ClusterName: testClusterName, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *ske.ApiTriggerRotateCredentialsRequest)) ske.ApiTriggerRotateCredentialsRequest { + request := testClient.TriggerRotateCredentials(testCtx, testProjectId, testClusterName) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest ske.ApiTriggerRotateCredentialsRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/ske/describe/describe.go b/internal/cmd/ske/describe/describe.go new file mode 100644 index 00000000..ed841938 --- /dev/null +++ b/internal/cmd/ske/describe/describe.go @@ -0,0 +1,97 @@ +package describe + +import ( + "context" + "encoding/json" + "fmt" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/services/ske/client" + "stackit/internal/pkg/tables" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/ske" +) + +type inputModel struct { + *globalflags.GlobalFlagModel +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "describe", + Short: "Get overall details regarding SKE", + Long: "Get overall details regarding STACKIT Kubernetes Engine (SKE)", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Get details regarding SKE functionality on your project`, + "$ stackit ske describe"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd) + if err != nil { + return err + } + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("read SKE project details: %w", err) + } + + return outputResult(cmd, model.OutputFormat, resp) + }, + } + return cmd +} + +func parseInput(cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *ske.APIClient) ske.ApiGetServiceStatusRequest { + req := apiClient.GetServiceStatus(ctx, model.ProjectId) + return req +} + +func outputResult(cmd *cobra.Command, outputFormat string, project *ske.ProjectResponse) error { + switch outputFormat { + case globalflags.PrettyOutputFormat: + table := tables.NewTable() + table.AddRow("ID", *project.ProjectId) + table.AddSeparator() + table.AddRow("STATE", *project.State) + err := table.Display(cmd) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + default: + details, err := json.MarshalIndent(project, "", " ") + if err != nil { + return fmt.Errorf("marshal SKE project details: %w", err) + } + cmd.Println(string(details)) + + return nil + } +} diff --git a/internal/cmd/ske/describe/describe_test.go b/internal/cmd/ske/describe/describe_test.go new file mode 100644 index 00000000..da9d32cd --- /dev/null +++ b/internal/cmd/ske/describe/describe_test.go @@ -0,0 +1,167 @@ +package describe + +import ( + "context" + "testing" + + "stackit/internal/pkg/globalflags" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/ske" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &ske.APIClient{} +var testProjectId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *ske.ApiGetServiceStatusRequest)) ske.ApiGetServiceStatusRequest { + request := testClient.GetServiceStatus(testCtx, testProjectId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := &cobra.Command{} + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + isValid bool + expectedRequest ske.ApiGetServiceStatusRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/ske/disable/disable.go b/internal/cmd/ske/disable/disable.go new file mode 100644 index 00000000..723363ad --- /dev/null +++ b/internal/cmd/ske/disable/disable.go @@ -0,0 +1,105 @@ +package disable + +import ( + "context" + "fmt" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/confirm" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/projectname" + "stackit/internal/pkg/services/ske/client" + "stackit/internal/pkg/spinner" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/ske" + "github.com/stackitcloud/stackit-sdk-go/services/ske/wait" +) + +type InputModel struct { + *globalflags.GlobalFlagModel +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "disable", + Short: "Disables SKE for a project", + Long: "Disables STACKIT Kubernetes Engine (SKE) for a project. It will delete all associated clusters", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Disable SKE functionality for your project, deleting all associated clusters`, + "$ stackit ske disable"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, cmd) + if err != nil { + projectLabel = model.ProjectId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to disable SKE for project %s? (This will delete all associated clusters)", projectLabel) + err = confirm.PromptForConfirmation(cmd, prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + _, err = req.Execute() + if err != nil { + return fmt.Errorf("disable SKE: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(cmd) + s.Start("Disabling SKE") + _, err = wait.DisableServiceWaitHandler(ctx, apiClient, model.ProjectId).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for SKE disabling: %w", err) + } + s.Stop() + } + + operationState := "Disabled" + if model.Async { + operationState = "Triggered disablement of" + } + cmd.Printf("%s SKE for project %s\n", operationState, projectLabel) + return nil + }, + } + return cmd +} + +func parseInput(cmd *cobra.Command) (*InputModel, error) { + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + return &InputModel{ + GlobalFlagModel: globalFlags, + }, nil +} + +func buildRequest(ctx context.Context, model *InputModel, apiClient *ske.APIClient) ske.ApiDisableServiceRequest { + req := apiClient.DisableService(ctx, model.ProjectId) + return req +} diff --git a/internal/cmd/ske/disable/disable_test.go b/internal/cmd/ske/disable/disable_test.go new file mode 100644 index 00000000..40de8d59 --- /dev/null +++ b/internal/cmd/ske/disable/disable_test.go @@ -0,0 +1,166 @@ +package disable + +import ( + "context" + "testing" + + "stackit/internal/pkg/globalflags" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/ske" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &ske.APIClient{} +var testProjectId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *InputModel)) *InputModel { + model := &InputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *ske.ApiDisableServiceRequest)) ske.ApiDisableServiceRequest { + request := testClient.DisableService(testCtx, testProjectId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + isValid bool + expectedModel *InputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := &cobra.Command{} + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *InputModel + expectedRequest ske.ApiDisableServiceRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/ske/enable/enable.go b/internal/cmd/ske/enable/enable.go new file mode 100644 index 00000000..178c3b51 --- /dev/null +++ b/internal/cmd/ske/enable/enable.go @@ -0,0 +1,105 @@ +package enable + +import ( + "context" + "fmt" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/confirm" + "stackit/internal/pkg/errors" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/projectname" + "stackit/internal/pkg/services/ske/client" + "stackit/internal/pkg/spinner" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/ske" + "github.com/stackitcloud/stackit-sdk-go/services/ske/wait" +) + +type InputModel struct { + *globalflags.GlobalFlagModel +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "enable", + Short: "Enables SKE for a project", + Long: "Enables STACKIT Kubernetes Engine (SKE) for a project", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Enable SKE functionality for your project`, + "$ stackit ske enable"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, cmd) + if err != nil { + projectLabel = model.ProjectId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to enable SKE for project %s?", projectLabel) + err = confirm.PromptForConfirmation(cmd, prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + _, err = req.Execute() + if err != nil { + return fmt.Errorf("enable SKE: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(cmd) + s.Start("Enabling SKE") + _, err = wait.EnableServiceWaitHandler(ctx, apiClient, model.ProjectId).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for SKE enabling: %w", err) + } + s.Stop() + } + + operationState := "Disabled" + if model.Async { + operationState = "Triggered disablement of" + } + cmd.Printf("%s SKE for project %s\n", operationState, projectLabel) + return nil + }, + } + return cmd +} + +func parseInput(cmd *cobra.Command) (*InputModel, error) { + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + return &InputModel{ + GlobalFlagModel: globalFlags, + }, nil +} + +func buildRequest(ctx context.Context, model *InputModel, apiClient *ske.APIClient) ske.ApiEnableServiceRequest { + req := apiClient.EnableService(ctx, model.ProjectId) + return req +} diff --git a/internal/cmd/ske/enable/enable_test.go b/internal/cmd/ske/enable/enable_test.go new file mode 100644 index 00000000..81234376 --- /dev/null +++ b/internal/cmd/ske/enable/enable_test.go @@ -0,0 +1,166 @@ +package enable + +import ( + "context" + "testing" + + "stackit/internal/pkg/globalflags" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/ske" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &ske.APIClient{} +var testProjectId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *InputModel)) *InputModel { + model := &InputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *ske.ApiEnableServiceRequest)) ske.ApiEnableServiceRequest { + request := testClient.EnableService(testCtx, testProjectId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + isValid bool + expectedModel *InputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := &cobra.Command{} + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *InputModel + expectedRequest ske.ApiEnableServiceRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/ske/options/options.go b/internal/cmd/ske/options/options.go new file mode 100644 index 00000000..95e12c24 --- /dev/null +++ b/internal/cmd/ske/options/options.go @@ -0,0 +1,248 @@ +package options + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "stackit/internal/pkg/args" + "stackit/internal/pkg/examples" + "stackit/internal/pkg/flags" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/pager" + "stackit/internal/pkg/services/ske/client" + "stackit/internal/pkg/tables" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/ske" +) + +const ( + availabilityZonesFlag = "availability-zones" + kubernetesVersionsFlag = "kubernetes-versions" + machineImagesFlag = "machine-images" + machineTypesFlag = "machine-types" + volumeTypesFlag = "volume-types" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + AvailabilityZones bool + KubernetesVersions bool + MachineImages bool + MachineTypes bool + VolumeTypes bool +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "options", + Short: "List SKE provider options", + Long: "List STACKIT Kubernetes Engine (SKE) provider options (availability zones, Kubernetes versions, machine images and types, volume types)\nPass one or more flags to filter what categories are shown", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List SKE options for all categories`, + "$ stackit ske options"), + examples.NewExample( + `List SKE options regarding Kubernetes versions only`, + "$ stackit ske options --kubernetes-versions"), + examples.NewExample( + `List SKE options regarding Kubernetes versions and machine images`, + "$ stackit ske options --kubernetes-versions --machine-images"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get SKE provider options: %w", err) + } + + return outputResult(cmd, model, resp) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Bool(availabilityZonesFlag, false, "Lists availability zones") + cmd.Flags().Bool(kubernetesVersionsFlag, false, "Lists supported kubernetes versions") + cmd.Flags().Bool(machineImagesFlag, false, "Lists supported machine images") + cmd.Flags().Bool(machineTypesFlag, false, "Lists supported machine types") + cmd.Flags().Bool(volumeTypesFlag, false, "Lists supported volume types") +} + +func parseInput(cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(cmd) + availabilityZones := flags.FlagToBoolValue(cmd, availabilityZonesFlag) + kubernetesVersions := flags.FlagToBoolValue(cmd, kubernetesVersionsFlag) + machineImages := flags.FlagToBoolValue(cmd, machineImagesFlag) + machineTypes := flags.FlagToBoolValue(cmd, machineTypesFlag) + volumeTypes := flags.FlagToBoolValue(cmd, volumeTypesFlag) + + // If no flag was passed, take it as if every flag were passed + if !availabilityZones && !kubernetesVersions && !machineImages && !machineTypes && !volumeTypes { + availabilityZones = true + kubernetesVersions = true + machineImages = true + machineTypes = true + volumeTypes = true + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + AvailabilityZones: availabilityZones, + KubernetesVersions: kubernetesVersions, + MachineImages: machineImages, + MachineTypes: machineTypes, + VolumeTypes: volumeTypes, + }, nil +} + +func buildRequest(ctx context.Context, apiClient *ske.APIClient) ske.ApiListProviderOptionsRequest { + req := apiClient.ListProviderOptions(ctx) + return req +} + +func outputResult(cmd *cobra.Command, model *inputModel, options *ske.ProviderOptions) error { + switch model.OutputFormat { + case globalflags.JSONOutputFormat: + details, err := json.MarshalIndent(options, "", " ") + if err != nil { + return fmt.Errorf("marshal SKE options: %w", err) + } + cmd.Println(string(details)) + return nil + default: + return outputResultAsTable(cmd, model, options) + } +} + +func outputResultAsTable(cmd *cobra.Command, model *inputModel, options *ske.ProviderOptions) error { + content := "" + if model.AvailabilityZones { + content += renderAvailabilityZones(options) + } + if model.KubernetesVersions { + kubernetesVersionsRendered, err := renderKubernetesVersions(options) + if err != nil { + return fmt.Errorf("render Kubernetes versions: %w", err) + } + content += kubernetesVersionsRendered + } + if model.MachineImages { + content += renderMachineImages(options) + } + if model.MachineTypes { + content += renderMachineTypes(options) + } + if model.VolumeTypes { + content += renderVolumeTypes(options) + } + + err := pager.Display(cmd, content) + if err != nil { + return fmt.Errorf("display output: %w", err) + } + + return nil +} + +func renderAvailabilityZones(resp *ske.ProviderOptions) string { + zones := *resp.AvailabilityZones + + table := tables.NewTable() + table.SetHeader("AVAILABILITY ZONES") + for i := range zones { + z := zones[i] + table.AddRow(*z.Name) + } + return table.Render() +} + +func renderKubernetesVersions(resp *ske.ProviderOptions) (string, error) { + versions := *resp.KubernetesVersions + + table := tables.NewTable() + table.SetHeader("KUBERNETES VERSION", "STATE", "EXPIRATION DATE", "FEATURE GATES") + for i := range versions { + v := versions[i] + featureGate, err := json.Marshal(*v.FeatureGates) + if err != nil { + return "", fmt.Errorf("marshal featureGates of Kubernetes version %q: %w", *v.Version, err) + } + expirationDate := "" + if v.ExpirationDate != nil { + expirationDate = *v.ExpirationDate + } + table.AddRow(*v.Version, *v.State, expirationDate, string(featureGate)) + } + return table.Render(), nil +} + +func renderMachineImages(resp *ske.ProviderOptions) string { + images := *resp.MachineImages + + table := tables.NewTable() + table.SetHeader("MACHINE IMAGE NAME", "VERSION", "STATE", "EXPIRATION DATE", "SUPPORTED CRI") + for i := range images { + image := images[i] + versions := *image.Versions + for j := range versions { + version := versions[j] + criNames := make([]string, 0) + for i := range *version.Cri { + cri := (*version.Cri)[i] + criNames = append(criNames, *cri.Name) + } + criNamesString := strings.Join(criNames, ", ") + + expirationDate := "-" + if version.ExpirationDate != nil { + expirationDate = *version.ExpirationDate + } + table.AddRow(*image.Name, *version.Version, *version.State, expirationDate, criNamesString) + } + } + table.EnableAutoMergeOnColumns(1) + return table.Render() +} + +func renderMachineTypes(resp *ske.ProviderOptions) string { + types := *resp.MachineTypes + + table := tables.NewTable() + table.SetHeader("MACHINE TYPE", "CPU", "MEMORY") + for i := range types { + t := types[i] + table.AddRow(*t.Name, *t.Cpu, *t.Memory) + } + return table.Render() +} + +func renderVolumeTypes(resp *ske.ProviderOptions) string { + types := *resp.VolumeTypes + + table := tables.NewTable() + table.SetHeader("VOLUME TYPE") + for i := range types { + z := types[i] + table.AddRow(*z.Name) + } + return table.Render() +} diff --git a/internal/cmd/ske/options/options_test.go b/internal/cmd/ske/options/options_test.go new file mode 100644 index 00000000..7e785ce3 --- /dev/null +++ b/internal/cmd/ske/options/options_test.go @@ -0,0 +1,185 @@ +package options + +import ( + "context" + "stackit/internal/pkg/globalflags" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/stackitcloud/stackit-sdk-go/services/ske" +) + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &ske.APIClient{} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + availabilityZonesFlag: "false", + kubernetesVersionsFlag: "false", + machineImagesFlag: "false", + machineTypesFlag: "false", + volumeTypesFlag: "false", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModelAllFalse(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{}, + AvailabilityZones: false, + KubernetesVersions: false, + MachineImages: false, + MachineTypes: false, + VolumeTypes: false, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureInputModelAllTrue(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{}, + AvailabilityZones: true, + KubernetesVersions: true, + MachineImages: true, + MachineTypes: true, + VolumeTypes: true, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModelAllTrue(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: true, + expectedModel: fixtureInputModelAllTrue(), + }, + { + description: "some values 1", + flagValues: map[string]string{ + availabilityZonesFlag: "true", + kubernetesVersionsFlag: "false", + }, + isValid: true, + expectedModel: fixtureInputModelAllFalse(func(model *inputModel) { + model.AvailabilityZones = true + }), + }, + { + description: "some values 2", + flagValues: map[string]string{ + kubernetesVersionsFlag: "true", + machineImagesFlag: "false", + machineTypesFlag: "true", + }, + isValid: true, + expectedModel: fixtureInputModelAllFalse(func(model *inputModel) { + model.KubernetesVersions = true + model.MachineTypes = true + }), + }, + { + description: "some values 3", + flagValues: map[string]string{ + kubernetesVersionsFlag: "false", + machineTypesFlag: "false", + }, + isValid: true, + expectedModel: fixtureInputModelAllTrue(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + expectedRequest ske.ApiListProviderOptionsRequest + }{ + { + description: "base", + expectedRequest: testClient.ListProviderOptions(testCtx), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/ske/ske.go b/internal/cmd/ske/ske.go new file mode 100644 index 00000000..5b08cc3b --- /dev/null +++ b/internal/cmd/ske/ske.go @@ -0,0 +1,35 @@ +package ske + +import ( + "stackit/internal/cmd/ske/cluster" + "stackit/internal/cmd/ske/credentials" + "stackit/internal/cmd/ske/describe" + "stackit/internal/cmd/ske/disable" + "stackit/internal/cmd/ske/enable" + "stackit/internal/cmd/ske/options" + "stackit/internal/pkg/args" + "stackit/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "ske", + Short: "Provides functionality for SKE", + Long: "Provides functionality for STACKIT Kubernetes Engine (SKE)", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd) + return cmd +} + +func addSubcommands(cmd *cobra.Command) { + cmd.AddCommand(describe.NewCmd()) + cmd.AddCommand(enable.NewCmd()) + cmd.AddCommand(disable.NewCmd()) + cmd.AddCommand(cluster.NewCmd()) + cmd.AddCommand(credentials.NewCmd()) + cmd.AddCommand(options.NewCmd()) +} diff --git a/internal/pkg/args/args.go b/internal/pkg/args/args.go new file mode 100644 index 00000000..13797739 --- /dev/null +++ b/internal/pkg/args/args.go @@ -0,0 +1,44 @@ +package args + +import ( + "stackit/internal/pkg/errors" + + "github.com/spf13/cobra" +) + +func NoArgs(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return nil + } + + return &errors.InputUnknownError{ + ProvidedInput: args[0], + Cmd: cmd, + } +} + +// SingleArg checks if only one argument was provided and validates it +// using the validate function. It returns an error if none or multiple arguments +// are provided, or if the argument is invalid. +// For no validation, you can pass a nil validate function +func SingleArg(argName string, validate func(value string) error) cobra.PositionalArgs { + return func(cmd *cobra.Command, args []string) error { + if len(args) != 1 { + return &errors.SingleArgExpectedError{ + Cmd: cmd, + Expected: argName, + Count: len(args), + } + } + if validate != nil { + err := validate(args[0]) + if err != nil { + return &errors.ArgValidationError{ + Arg: argName, + Details: err.Error(), + } + } + } + return nil + } +} diff --git a/internal/pkg/args/args_test.go b/internal/pkg/args/args_test.go new file mode 100644 index 00000000..951a1931 --- /dev/null +++ b/internal/pkg/args/args_test.go @@ -0,0 +1,104 @@ +package args + +import ( + "fmt" + "testing" + + "github.com/spf13/cobra" +) + +func TestNoArgs(t *testing.T) { + tests := []struct { + description string + args []string + isValid bool + }{ + { + description: "valid", + args: nil, + isValid: true, + }, + { + description: "invalid", + args: []string{"unknown"}, + isValid: false, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := &cobra.Command{ + Use: "test", + Short: "Test command", + } + + err := NoArgs(cmd, tt.args) + + if tt.isValid && err != nil { + t.Fatalf("should not have failed: %v", err) + } + if !tt.isValid && err == nil { + t.Fatalf("should have failed") + } + }) + } +} + +func TestSingleArg(t *testing.T) { + tests := []struct { + description string + args []string + validateFunc func(value string) error + isValid bool + }{ + { + description: "valid", + args: []string{"arg"}, + validateFunc: func(value string) error { + return nil + }, + isValid: true, + }, + { + description: "no_arg", + args: []string{}, + isValid: false, + }, + { + description: "more_than_one_arg", + args: []string{"arg", "arg2"}, + isValid: false, + }, + { + description: "invalid_arg", + args: []string{"arg"}, + validateFunc: func(value string) error { + return fmt.Errorf("error") + }, + isValid: false, + }, + { + description: "nil validation function", + args: []string{"arg"}, + validateFunc: nil, + isValid: true, + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := &cobra.Command{ + Use: "test", + Short: "Test command", + } + + argFunction := SingleArg("test", tt.validateFunc) + err := argFunction(cmd, tt.args) + + if tt.isValid && err != nil { + t.Fatalf("should not have failed: %v", err) + } + if !tt.isValid && err == nil { + t.Fatalf("should have failed") + } + }) + } +} diff --git a/internal/pkg/auth/auth.go b/internal/pkg/auth/auth.go new file mode 100644 index 00000000..1ccb4146 --- /dev/null +++ b/internal/pkg/auth/auth.go @@ -0,0 +1,123 @@ +package auth + +import ( + "fmt" + "strconv" + "time" + + "stackit/internal/pkg/config" + + "github.com/golang-jwt/jwt/v5" + "github.com/spf13/cobra" + "github.com/spf13/viper" + sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" +) + +type tokenClaims struct { + Email string `json:"email"` + jwt.RegisteredClaims +} + +// AuthenticationConfig reads the credentials from the storage and initializes the authentication flow. +// It returns the configuration option that can be used to create an authenticated SDK client. +// +// If the user was logged in and the user session expired, reauthorizeUserRoutine is called to reauthenticate the user again. +func AuthenticationConfig(cmd *cobra.Command, reauthorizeUserRoutine func() error) (authCfgOption sdkConfig.ConfigurationOption, err error) { + flow, err := GetAuthFlow() + if err != nil { + return nil, fmt.Errorf("get authentication flow: %w", err) + } + if flow == "" { + return nil, fmt.Errorf("authentication flow not set") + } + + userSessionExpired, err := userSessionExpired() + if err != nil { + return nil, fmt.Errorf("check if user session expired: %w", err) + } + + switch flow { + case AUTH_FLOW_SERVICE_ACCOUNT_TOKEN: + if userSessionExpired { + return nil, fmt.Errorf("session expired") + } + accessToken, err := getAccessToken() + if err != nil { + return nil, fmt.Errorf("get service account access token: %w", err) + } + authCfgOption = sdkConfig.WithToken(accessToken) + case AUTH_FLOW_SERVICE_ACCOUNT_KEY: + if userSessionExpired { + return nil, fmt.Errorf("session expired") + } + keyFlow, err := initKeyFlowWithStorage() + if err != nil { + return nil, fmt.Errorf("initialize service account key flow: %w", err) + } + authCfgOption = sdkConfig.WithCustomAuth(keyFlow) + case AUTH_FLOW_USER_TOKEN: + if userSessionExpired { + cmd.Println("Session expired, logging in again...") + err = reauthorizeUserRoutine() + if err != nil { + return nil, fmt.Errorf("user login: %w", err) + } + } + userTokenFlow := UserTokenFlow(cmd) + authCfgOption = sdkConfig.WithCustomAuth(userTokenFlow) + default: + return nil, fmt.Errorf("the provided authentication flow (%s) is not supported", flow) + } + return authCfgOption, nil +} + +func userSessionExpired() (bool, error) { + sessionExpiresAtString, err := GetAuthField(SESSION_EXPIRES_AT_UNIX) + if err != nil { + return false, fmt.Errorf("get %s: %w", SESSION_EXPIRES_AT_UNIX, err) + } + sessionExpiresAtInt, err := strconv.Atoi(sessionExpiresAtString) + if err != nil { + return false, fmt.Errorf("parse session expiration value \"%s\": %w", sessionExpiresAtString, err) + } + sessionExpiresAt := time.Unix(int64(sessionExpiresAtInt), 0) + now := time.Now() + return now.After(sessionExpiresAt), nil +} + +func getAccessToken() (string, error) { + accessToken, err := GetAuthField(ACCESS_TOKEN) + if err != nil { + return "", fmt.Errorf("get %s: %w", ACCESS_TOKEN, err) + } + if accessToken == "" { + return "", fmt.Errorf("%s not set", ACCESS_TOKEN) + } + return accessToken, nil +} + +func getStartingSessionExpiresAtUnix() (string, error) { + sessionStart := time.Now() + sessionTimeLimitString := viper.GetString(config.SessionTimeLimitKey) + sessionTimeLimit, err := time.ParseDuration(sessionTimeLimitString) + if err != nil { + return "", fmt.Errorf("parse session time limit \"%s\": %w", sessionTimeLimitString, err) + } + sessionExpiresAt := sessionStart.Add(sessionTimeLimit) + return strconv.FormatInt(sessionExpiresAt.Unix(), 10), nil +} + +func getEmailFromToken(token string) (string, error) { + // We can safely use ParseUnverified because we are not authenticating the user at this point, + // We are parsing the token just to get the service account e-mail + parsedAccessToken, _, err := jwt.NewParser().ParseUnverified(token, &tokenClaims{}) + if err != nil { + return "", fmt.Errorf("parse token: %w", err) + } + claims, ok := parsedAccessToken.Claims.(*tokenClaims) + if !ok { + return "", fmt.Errorf("get claims from parsed token: unknown claims type, please report this issue") + } + + return claims.Email, nil +} diff --git a/internal/pkg/auth/auth_test.go b/internal/pkg/auth/auth_test.go new file mode 100644 index 00000000..21b2867a --- /dev/null +++ b/internal/pkg/auth/auth_test.go @@ -0,0 +1,356 @@ +package auth + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + "io" + "strconv" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/go-cmp/cmp" + "github.com/google/uuid" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/core/clients" + sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/zalando/go-keyring" +) + +const saKeyStrPattern = `{ + "active": true, + "createdAt": "2023-03-23T18:26:20.335Z", + "credentials": { + "aud": "https://stackit-service-account-prod.apps.01.cf.eu01.stackit.cloud", + "iss": "stackit@sa.stackit.cloud", + "kid": "%s", + "sub": "%s" + }, + "id": "%s", + "keyAlgorithm": "RSA_2048", + "keyOrigin": "USER_PROVIDED", + "keyType": "USER_MANAGED", + "publicKey": "...", + "validUntil": "2024-03-22T18:05:41Z" +}` + +var ( + testSigningKey = []byte("Test") + testServiceAccountKey = fmt.Sprintf(saKeyStrPattern, uuid.New().String(), uuid.New().String(), uuid.New().String()) +) + +func generatePrivateKey() ([]byte, error) { + // Generate a new RSA key pair with a size of 2048 bits + privKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, err + } + + // Encode the private key in PEM format + privKeyPEM := &pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(privKey), + } + + // Print the private and public keys + return pem.EncodeToMemory(privKeyPEM), nil +} + +func TestAuthenticationConfig(t *testing.T) { + tests := []struct { + description string + flow AuthFlow + sessionExpiresAt time.Time + accessTokenSet bool + refreshToken string + saKey string + privateKeySet bool + tokenEndpoint string + jwksEndpoint string + isValid bool + expectedCustomAuthSet bool + expectedTokenSet bool + expectedReauthorizeUserCalled bool + }{ + { + description: "base_service_account_token", + flow: AUTH_FLOW_SERVICE_ACCOUNT_TOKEN, + sessionExpiresAt: time.Now().Add(time.Hour), + accessTokenSet: true, + refreshToken: "refresh_token", + isValid: true, + expectedTokenSet: true, + }, + { + description: "service_account_token_session_expired", + flow: AUTH_FLOW_SERVICE_ACCOUNT_TOKEN, + sessionExpiresAt: time.Now().Add(-time.Hour), + accessTokenSet: true, + refreshToken: "refresh_token", + isValid: false, + }, + { + description: "base_service_account_key", + flow: AUTH_FLOW_SERVICE_ACCOUNT_KEY, + sessionExpiresAt: time.Now().Add(time.Hour), + accessTokenSet: true, + refreshToken: "refresh_token", + saKey: testServiceAccountKey, + privateKeySet: true, + tokenEndpoint: "token_url", + jwksEndpoint: "jwks_url", + isValid: true, + expectedCustomAuthSet: true, + }, + { + description: "service_account_key_session_expired", + flow: AUTH_FLOW_SERVICE_ACCOUNT_KEY, + sessionExpiresAt: time.Now().Add(-time.Hour), + accessTokenSet: true, + refreshToken: "refresh_token", + saKey: testServiceAccountKey, + privateKeySet: true, + tokenEndpoint: "token_url", + jwksEndpoint: "jwks_url", + isValid: false, + }, + { + description: "base_user_token", + flow: AUTH_FLOW_USER_TOKEN, + sessionExpiresAt: time.Now().Add(time.Hour), + accessTokenSet: true, + refreshToken: "refresh_token", + isValid: true, + }, + { + description: "user_token_session_expired", + flow: AUTH_FLOW_USER_TOKEN, + sessionExpiresAt: time.Now().Add(-time.Hour), + accessTokenSet: true, + refreshToken: "refresh_token", + isValid: true, + expectedReauthorizeUserCalled: true, + }, + { + description: "unsupported_flow", + flow: "test_flow", + isValid: false, + }, + { + description: "unset_access_token", + accessTokenSet: false, + isValid: false, + }, + { + description: "unset_flow", + flow: "", + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + keyring.MockInit() + timestamp := time.Now().Add(24 * time.Hour) + authFields := make(map[authFieldKey]string) + var accessToken string + var err error + if tt.accessTokenSet { + accessTokenJWT := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(timestamp)}) + accessToken, err = accessTokenJWT.SignedString(testSigningKey) + if err != nil { + t.Fatalf("Get test access token as string: %s", err) + } + } + + if tt.privateKeySet { + privateKey, err := generatePrivateKey() + if err != nil { + t.Fatalf("Generate private key: %s", err) + } + authFields[PRIVATE_KEY] = string(privateKey) + } + authFields[SESSION_EXPIRES_AT_UNIX] = strconv.FormatInt(tt.sessionExpiresAt.Unix(), 10) + authFields[ACCESS_TOKEN] = accessToken + authFields[REFRESH_TOKEN] = tt.refreshToken + authFields[SERVICE_ACCOUNT_KEY] = tt.saKey + authFields[TOKEN_CUSTOM_ENDPOINT] = tt.tokenEndpoint + authFields[JWKS_CUSTOM_ENDPOINT] = tt.jwksEndpoint + + err = SetAuthFlow(tt.flow) + if err != nil { + t.Fatalf("Failed to set auth flow: %s", err) + } + err = SetAuthFieldMap(authFields) + if err != nil { + t.Fatalf("Failed to set in auth storage: %v", err) + } + + reauthorizeUserCalled := false + reauthenticateUser := func() error { + if reauthorizeUserCalled { + t.Errorf("user reauthorized more than once") + } + reauthorizeUserCalled = true + return nil + } + + cmd := &cobra.Command{} + cmd.SetOut(io.Discard) // Suppresses console prints + + authCfgOption, err := AuthenticationConfig(cmd, reauthenticateUser) + + if !tt.isValid { + if err == nil { + t.Fatalf("Expected error but no error was returned") + } + } else { + if err != nil { + t.Fatalf("Expected no error but error was returned: %v", err) + } + + if reauthorizeUserCalled && !tt.expectedReauthorizeUserCalled { + t.Errorf("Unexpected user reauthentication") + } else if !reauthorizeUserCalled && tt.expectedReauthorizeUserCalled { + t.Errorf("User wasn't reauthenticated when it should've been") + } + + baseCfg := &sdkConfig.Configuration{} + err := authCfgOption(baseCfg) + if err != nil { + t.Fatalf("Applying returned auth config option: %v", err) + } + if tt.expectedCustomAuthSet && baseCfg.CustomAuth == nil { + t.Fatalf("The returned auth configuration option should set the CustomAuth field but it is nil") + } + if tt.expectedTokenSet && baseCfg.Token == "" { + t.Fatalf("The returned auth configuration option should set the Token field but it is empty") + } + } + }) + } +} + +func TestInitKeyFlow(t *testing.T) { + tests := []struct { + description string + accessTokenSet bool + refreshToken string + saKey string + privateKeySet bool + tokenEndpoint string + jwksEndpoint string + isValid bool + }{ + { + description: "base", + accessTokenSet: true, + refreshToken: "refresh_token", + saKey: testServiceAccountKey, + privateKeySet: true, + tokenEndpoint: "token_url", + jwksEndpoint: "jwks_url", + isValid: true, + }, + { + description: "invalid_service_account_key", + accessTokenSet: true, + refreshToken: "refresh_token", + saKey: "", + privateKeySet: true, + tokenEndpoint: "token_url", + jwksEndpoint: "jwks_url", + isValid: false, + }, + { + description: "invalid_private_key", + accessTokenSet: true, + refreshToken: "refresh_token", + saKey: testServiceAccountKey, + privateKeySet: false, + tokenEndpoint: "token_url", + jwksEndpoint: "jwks_url", + isValid: false, + }, + { + description: "invalid_access_token", + accessTokenSet: false, + refreshToken: "refresh_token", + saKey: testServiceAccountKey, + privateKeySet: true, + tokenEndpoint: "token_url", + jwksEndpoint: "jwks_url", + isValid: false, + }, + { + description: "empty_refresh_token", + accessTokenSet: false, + refreshToken: "", + saKey: testServiceAccountKey, + privateKeySet: true, + tokenEndpoint: "token_url", + jwksEndpoint: "jwks_url", + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + keyring.MockInit() + timestamp := time.Now().Add(24 * time.Hour) + authFields := make(map[authFieldKey]string) + var accessToken string + var err error + if tt.accessTokenSet { + accessTokenJWT := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(timestamp)}) + accessToken, err = accessTokenJWT.SignedString(testSigningKey) + if err != nil { + t.Fatalf("Get test access token as string: %s", err) + } + } + if tt.privateKeySet { + privateKey, err := generatePrivateKey() + if err != nil { + t.Fatalf("Generate private key: %s", err) + } + authFields[PRIVATE_KEY] = string(privateKey) + } + authFields[ACCESS_TOKEN] = accessToken + authFields[REFRESH_TOKEN] = tt.refreshToken + authFields[SERVICE_ACCOUNT_KEY] = tt.saKey + authFields[TOKEN_CUSTOM_ENDPOINT] = tt.tokenEndpoint + authFields[JWKS_CUSTOM_ENDPOINT] = tt.jwksEndpoint + err = SetAuthFieldMap(authFields) + if err != nil { + t.Fatalf("Failed to set in auth storage: %v", err) + } + + keyFlowWithStorage, err := initKeyFlowWithStorage() + + if !tt.isValid { + if err == nil { + t.Fatalf("Expected error but no error was returned") + } + } else { + if err != nil { + t.Fatalf("Expected no error but error was returned: %v", err) + } + expectedToken := &clients.TokenResponseBody{ + AccessToken: accessToken, + ExpiresIn: int(timestamp.Unix()), + RefreshToken: tt.refreshToken, + Scope: "", + TokenType: "Bearer", + } + if !cmp.Equal(*expectedToken, keyFlowWithStorage.keyFlow.GetToken()) { + t.Errorf("The returned result is wrong. Expected %+v, got %+v", expectedToken, keyFlowWithStorage.keyFlow.GetToken()) + } + } + }) + } +} diff --git a/internal/pkg/auth/login-successful.html b/internal/pkg/auth/login-successful.html new file mode 100644 index 00000000..328fe8c4 --- /dev/null +++ b/internal/pkg/auth/login-successful.html @@ -0,0 +1,18 @@ + + + +

Hello {{.Email}}, your login was successful!

+

You can close this window and return to the STACKIT CLI, or we will redirect you to the STACKIT website in 1 minute.

+

For more info and documentation, check the CLI repo

+ + + + \ No newline at end of file diff --git a/internal/pkg/auth/service_account.go b/internal/pkg/auth/service_account.go new file mode 100644 index 00000000..2b7a9045 --- /dev/null +++ b/internal/pkg/auth/service_account.go @@ -0,0 +1,160 @@ +package auth + +import ( + "encoding/json" + "fmt" + "net/http" + "stackit/internal/pkg/errors" + + "github.com/stackitcloud/stackit-sdk-go/core/clients" +) + +type keyFlowInterface interface { + GetAccessToken() (string, error) + GetConfig() clients.KeyFlowConfig + GetToken() clients.TokenResponseBody + RoundTrip(*http.Request) (*http.Response, error) +} + +type tokenFlowInterface interface { + GetConfig() clients.TokenFlowConfig + RoundTrip(*http.Request) (*http.Response, error) +} + +type keyFlowWithStorage struct { + keyFlow *clients.KeyFlow +} + +// Ensure the implementation satisfies the expected interface +var _ http.RoundTripper = &keyFlowWithStorage{} + +// AuthenticateServiceAccount checks the type of the provided roundtripper, +// authenticates the CLI accordingly and store the credentials. +// For the key flow, it fetches an access and refresh token from the Service Account API. +// For the token flow, it just stores the provided token and doesn't check if it is valid. +// It returns the email associated with the service account +func AuthenticateServiceAccount(rt http.RoundTripper) (email string, err error) { + authFields := make(map[authFieldKey]string) + var authFlowType AuthFlow + switch flow := rt.(type) { + case keyFlowInterface: + authFlowType = AUTH_FLOW_SERVICE_ACCOUNT_KEY + + accessToken, err := flow.GetAccessToken() + if err != nil { + return "", &errors.ActivateServiceAccountError{} + } + serviceAccountKey := flow.GetConfig().ServiceAccountKey + saKeyBytes, err := json.Marshal(serviceAccountKey) + if err != nil { + return "", fmt.Errorf("marshal service account key: %w", err) + } + + authFields[ACCESS_TOKEN] = accessToken + authFields[REFRESH_TOKEN] = flow.GetToken().RefreshToken + authFields[SERVICE_ACCOUNT_KEY] = string(saKeyBytes) + authFields[PRIVATE_KEY] = flow.GetConfig().PrivateKey + case tokenFlowInterface: + authFlowType = AUTH_FLOW_SERVICE_ACCOUNT_TOKEN + + authFields[ACCESS_TOKEN] = flow.GetConfig().ServiceAccountToken + default: + return "", fmt.Errorf("could not authenticate using any of the supported authentication flows (key and token): please report this issue") + } + + email, err = getEmailFromToken(authFields[ACCESS_TOKEN]) + if err != nil { + return "", fmt.Errorf("get email from access token: %w", err) + } + + authFields[SERVICE_ACCOUNT_EMAIL] = email + + sessionExpiresAtUnix, err := getStartingSessionExpiresAtUnix() + if err != nil { + return "", fmt.Errorf("compute session expiration timestamp: %w", err) + } + authFields[SESSION_EXPIRES_AT_UNIX] = sessionExpiresAtUnix + + err = SetAuthFlow(authFlowType) + if err != nil { + return "", fmt.Errorf("set auth flow type: %w", err) + } + err = SetAuthFieldMap(authFields) + if err != nil { + return "", fmt.Errorf("set in auth storage: %w", err) + } + + return authFields[SERVICE_ACCOUNT_EMAIL], nil +} + +// initKeyFlowWithStorage initializes the keyFlow from the SDK and creates a keyFlowWithStorage struct that uses that keyFlow +func initKeyFlowWithStorage() (*keyFlowWithStorage, error) { + authFields := map[authFieldKey]string{ + ACCESS_TOKEN: "", + REFRESH_TOKEN: "", + SERVICE_ACCOUNT_KEY: "", + PRIVATE_KEY: "", + TOKEN_CUSTOM_ENDPOINT: "", + JWKS_CUSTOM_ENDPOINT: "", + } + err := GetAuthFieldMap(authFields) + if err != nil { + return nil, fmt.Errorf("get from auth storage: %w", err) + } + if authFields[ACCESS_TOKEN] == "" { + return nil, fmt.Errorf("access token not set") + } + if authFields[REFRESH_TOKEN] == "" { + return nil, fmt.Errorf("refresh token not set") + } + + var serviceAccountKey = &clients.ServiceAccountKeyResponse{} + err = json.Unmarshal([]byte(authFields[SERVICE_ACCOUNT_KEY]), serviceAccountKey) + if err != nil { + return nil, fmt.Errorf("unmarshalling service account key: %w", err) + } + + cfg := &clients.KeyFlowConfig{ + ServiceAccountKey: serviceAccountKey, + PrivateKey: authFields[PRIVATE_KEY], + ClientRetry: clients.NewRetryConfig(), + TokenUrl: authFields[TOKEN_CUSTOM_ENDPOINT], + JWKSUrl: authFields[JWKS_CUSTOM_ENDPOINT], + } + + keyFlow := &clients.KeyFlow{} + err = keyFlow.Init(cfg) + if err != nil { + return nil, fmt.Errorf("initialize key flow: %w", err) + } + err = keyFlow.SetToken(authFields[ACCESS_TOKEN], authFields[REFRESH_TOKEN]) + if err != nil { + return nil, fmt.Errorf("set access and refresh token: %w", err) + } + + // create keyFlowWithStorage roundtripper that stores the credentials after executing a request + keyFlowWithStorage := &keyFlowWithStorage{ + keyFlow: keyFlow, + } + return keyFlowWithStorage, nil +} + +// The keyFlowWithStorage Roundtrip executes the keyFlow roundtrip and then stores the access and refresh tokens +func (kf *keyFlowWithStorage) RoundTrip(req *http.Request) (*http.Response, error) { + resp, err := kf.keyFlow.RoundTrip(req) + + token := kf.keyFlow.GetToken() + accessToken := token.AccessToken + refreshToken := token.RefreshToken + tokenValues := map[authFieldKey]string{ + ACCESS_TOKEN: accessToken, + REFRESH_TOKEN: refreshToken, + } + + storageErr := SetAuthFieldMap(tokenValues) + if storageErr != nil { + return nil, fmt.Errorf("set access and refresh token in the storage: %w", err) + } + + return resp, err +} diff --git a/internal/pkg/auth/service_account_test.go b/internal/pkg/auth/service_account_test.go new file mode 100644 index 00000000..a9a91dca --- /dev/null +++ b/internal/pkg/auth/service_account_test.go @@ -0,0 +1,170 @@ +package auth + +import ( + "fmt" + "net/http" + "testing" + + "stackit/internal/pkg/config" + + "github.com/golang-jwt/jwt/v5" + "github.com/stackitcloud/stackit-sdk-go/core/clients" + "github.com/zalando/go-keyring" +) + +const ( + tokenFlow = "token" + keyFlow = "key" +) + +var accessTokenSigningKey = []byte("Test") + +type keyFlowMocked struct { + accessToken jwt.Token + config clients.KeyFlowConfig + tokenResponse clients.TokenResponseBody + getAccessTokenFail bool + tokenInvalid bool +} + +func (f *keyFlowMocked) GetAccessToken() (string, error) { + if f.getAccessTokenFail { + return "", fmt.Errorf("error") + } + if f.tokenInvalid { + return "", nil + } + raw, err := f.accessToken.SignedString(accessTokenSigningKey) + if err != nil { + return "", fmt.Errorf("sign string from token: %w", err) + } + return raw, nil +} + +func (f *keyFlowMocked) GetConfig() clients.KeyFlowConfig { + return f.config +} + +func (f *keyFlowMocked) GetToken() clients.TokenResponseBody { + return f.tokenResponse +} + +func (f *keyFlowMocked) RoundTrip(*http.Request) (*http.Response, error) { + return nil, nil +} + +type tokenFlowMocked struct { + config clients.TokenFlowConfig +} + +func (f *tokenFlowMocked) GetConfig() clients.TokenFlowConfig { + return f.config +} + +func (f *tokenFlowMocked) RoundTrip(*http.Request) (*http.Response, error) { + return nil, nil +} + +func TestAuthenticateServiceAccount(t *testing.T) { + tests := []struct { + description string + flowType string + getAccessTokenFail bool + tokenInvalid bool + accessToken jwt.Token + accessTokenRaw string + refreshToken string + expectedEmail string + isValid bool + }{ + { + description: "base_key_flow", + flowType: keyFlow, + accessToken: *jwt.NewWithClaims(jwt.SigningMethodHS256, &tokenClaims{ + Email: "test_email", + RegisteredClaims: jwt.RegisteredClaims{}, + }), + refreshToken: "refresh_token", + expectedEmail: "test_email", + isValid: true, + }, + { + description: "base_token_flow", + flowType: tokenFlow, + accessToken: *jwt.NewWithClaims(jwt.SigningMethodHS256, &tokenClaims{ + Email: "test_email", + }), + refreshToken: "refresh_token", + expectedEmail: "test_email", + isValid: true, + }, + { + description: "unsupported_flow", + flowType: "unsupported", + isValid: false, + }, + { + description: "key_flow_failed_get_access_token", + flowType: keyFlow, + getAccessTokenFail: true, + isValid: false, + }, + { + description: "invalid_token", + flowType: keyFlow, + tokenInvalid: true, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + keyring.MockInit() + config.InitConfig() // AuthenticateServiceAccount accesses the config + + var flow http.RoundTripper + switch tt.flowType { + case keyFlow: + flow = &keyFlowMocked{ + accessToken: tt.accessToken, + getAccessTokenFail: tt.getAccessTokenFail, + tokenInvalid: tt.tokenInvalid, + config: clients.KeyFlowConfig{ + ServiceAccountKey: &clients.ServiceAccountKeyResponse{}, + PrivateKey: "private_key", + }, + tokenResponse: clients.TokenResponseBody{ + RefreshToken: tt.refreshToken, + }, + } + case tokenFlow: + raw, err := tt.accessToken.SignedString(accessTokenSigningKey) + if err != nil { + t.Fatalf("signing string from token: %s", err) + } + flow = &tokenFlowMocked{ + config: clients.TokenFlowConfig{ + ServiceAccountToken: raw, + }, + } + default: + flow = &http.Transport{} + } + + email, err := AuthenticateServiceAccount(flow) + + if !tt.isValid { + if err == nil { + t.Fatalf("Expected error but no error was returned") + } + } else { + if err != nil { + t.Fatalf("Expected no error but error was returned: %v", err) + } + if tt.expectedEmail != email { + t.Fatalf("The returned email is wrong. Expected %s, got %s", tt.expectedEmail, email) + } + } + }) + } +} diff --git a/internal/pkg/auth/storage.go b/internal/pkg/auth/storage.go new file mode 100644 index 00000000..3a2031ff --- /dev/null +++ b/internal/pkg/auth/storage.go @@ -0,0 +1,209 @@ +package auth + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/zalando/go-keyring" +) + +// Name of an auth-related field +type authFieldKey string + +// Possible values of authentication flows +type AuthFlow string + +const ( + keyringService = "stackit-cli" + textFileFolderName = ".stackit" + textFileName = "cli-auth-storage.txt" +) + +const ( + SESSION_EXPIRES_AT_UNIX authFieldKey = "session_expires_at_unix" + ACCESS_TOKEN authFieldKey = "access_token" + REFRESH_TOKEN authFieldKey = "refresh_token" + SERVICE_ACCOUNT_TOKEN authFieldKey = "service_account_token" + SERVICE_ACCOUNT_EMAIL authFieldKey = "service_account_email" + USER_EMAIL authFieldKey = "user_email" + SERVICE_ACCOUNT_KEY authFieldKey = "service_account_key" + PRIVATE_KEY authFieldKey = "private_key" + TOKEN_CUSTOM_ENDPOINT authFieldKey = "token_custom_endpoint" + JWKS_CUSTOM_ENDPOINT authFieldKey = "jwks_custom_endpoint" +) + +const ( + authFlowType authFieldKey = "auth_flow_type" + AUTH_FLOW_USER_TOKEN AuthFlow = "user_token" + AUTH_FLOW_SERVICE_ACCOUNT_TOKEN AuthFlow = "sa_token" + AUTH_FLOW_SERVICE_ACCOUNT_KEY AuthFlow = "sa_key" +) + +func SetAuthFlow(value AuthFlow) error { + return SetAuthField(authFlowType, string(value)) +} + +// Sets the values in the auth storage according to the given map +func SetAuthFieldMap(keyMap map[authFieldKey]string) error { + for key, value := range keyMap { + err := SetAuthField(key, value) + if err != nil { + return fmt.Errorf("set auth field \"%s\": %w", key, err) + } + } + return nil +} + +func SetAuthField(key authFieldKey, value string) error { + err := setAuthFieldInKeyring(key, value) + if err != nil { + errFallback := setAuthFieldInEncodedTextFile(key, value) + if errFallback != nil { + return fmt.Errorf("write to keyring failed (%w), try writing to encoded text file: %w", err, errFallback) + } + } + return nil +} + +func setAuthFieldInKeyring(key authFieldKey, value string) error { + return keyring.Set(keyringService, string(key), value) +} + +func setAuthFieldInEncodedTextFile(key authFieldKey, value string) error { + err := createEncodedTextFile() + if err != nil { + return err + } + + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("get home dir: %w", err) + } + textFileDir := filepath.Join(homeDir, textFileFolderName) + textFilePath := filepath.Join(textFileDir, textFileName) + + contentEncoded, err := os.ReadFile(textFilePath) + if err != nil { + return fmt.Errorf("read file: %w", err) + } + contentBytes, err := base64.StdEncoding.DecodeString(string(contentEncoded)) + if err != nil { + return fmt.Errorf("decode file: %w", err) + } + content := map[authFieldKey]string{} + err = json.Unmarshal(contentBytes, &content) + if err != nil { + return fmt.Errorf("unmarshal file: %w", err) + } + + content[key] = value + + contentBytes, err = json.Marshal(content) + if err != nil { + return fmt.Errorf("marshal file: %w", err) + } + contentEncoded = []byte(base64.StdEncoding.EncodeToString(contentBytes)) + err = os.WriteFile(textFilePath, contentEncoded, 0o600) + if err != nil { + return fmt.Errorf("write file: %w", err) + } + return nil +} + +// Populates the values in the given map according to the auth storage +func GetAuthFieldMap(keyMap map[authFieldKey]string) error { + for key := range keyMap { + value, err := GetAuthField(key) + if err != nil { + return fmt.Errorf("get auth field \"%s\": %w", key, err) + } + keyMap[key] = value + } + return nil +} + +func GetAuthFlow() (AuthFlow, error) { + value, err := GetAuthField(authFlowType) + return AuthFlow(value), err +} + +func GetAuthField(key authFieldKey) (string, error) { + value, err := getAuthFieldFromKeyring(key) + if err != nil { + var errFallback error + value, errFallback = getAuthFieldFromEncodedTextFile(key) + if errFallback != nil { + return "", fmt.Errorf("write to keyring failed (%w), tried write to encoded text file: %w", err, errFallback) + } + } + return value, nil +} + +func getAuthFieldFromKeyring(key authFieldKey) (string, error) { + return keyring.Get(keyringService, string(key)) +} + +func getAuthFieldFromEncodedTextFile(key authFieldKey) (string, error) { + err := createEncodedTextFile() + if err != nil { + return "", err + } + + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("get home dir: %w", err) + } + textFileDir := filepath.Join(homeDir, textFileFolderName) + textFilePath := filepath.Join(textFileDir, textFileName) + + contentEncoded, err := os.ReadFile(textFilePath) + if err != nil { + return "", fmt.Errorf("read file: %w", err) + } + contentBytes, err := base64.StdEncoding.DecodeString(string(contentEncoded)) + if err != nil { + return "", fmt.Errorf("decode file: %w", err) + } + var content map[authFieldKey]string + err = json.Unmarshal(contentBytes, &content) + if err != nil { + return "", fmt.Errorf("unmarshal file: %w", err) + } + value, ok := content[key] + if !ok { + return "", fmt.Errorf("value not found") + } + return value, nil +} + +// Checks if the encoded text file exist. +// If it doesn't, creates it with the content "{}" encoded. +// If it does, does nothing (and returns nil). +func createEncodedTextFile() error { + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("get home dir: %w", err) + } + textFileDir := filepath.Join(homeDir, textFileFolderName) + textFilePath := filepath.Join(textFileDir, textFileName) + + err = os.MkdirAll(textFileDir, os.ModePerm) + if err != nil { + return fmt.Errorf("create file dir: %w", err) + } + _, err = os.Stat(textFilePath) + if !os.IsNotExist(err) { + return nil + } + + contentEncoded := base64.StdEncoding.EncodeToString([]byte("{}")) + err = os.WriteFile(textFilePath, []byte(contentEncoded), 0o600) + if err != nil { + return fmt.Errorf("create file: %w", err) + } + + return nil +} diff --git a/internal/pkg/auth/storage_test.go b/internal/pkg/auth/storage_test.go new file mode 100644 index 00000000..5b09c3c3 --- /dev/null +++ b/internal/pkg/auth/storage_test.go @@ -0,0 +1,381 @@ +package auth + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/zalando/go-keyring" +) + +func TestSetGetAuthField(t *testing.T) { + var testField1 authFieldKey = "test-field-1" + var testField2 authFieldKey = "test-field-2" + + testValue1 := fmt.Sprintf("value-1-%s", time.Now().Format(time.RFC3339)) + testValue2 := fmt.Sprintf("value-2-%s", time.Now().Format(time.RFC3339)) + testValue3 := fmt.Sprintf("value-3-%s", time.Now().Format(time.RFC3339)) + + type valueAssignment struct { + key authFieldKey + value string + } + + tests := []struct { + description string + keyringFails bool + valueAssignments []valueAssignment + expectedValues map[authFieldKey]string + }{ + { + description: "simple assignments", + valueAssignments: []valueAssignment{ + { + key: testField1, + value: testValue1, + }, + { + key: testField2, + value: testValue2, + }, + }, + expectedValues: map[authFieldKey]string{ + testField1: testValue1, + testField2: testValue2, + }, + }, + { + description: "simple assignments w/ keyring failing", + keyringFails: true, + valueAssignments: []valueAssignment{ + { + key: testField1, + value: testValue1, + }, + { + key: testField2, + value: testValue2, + }, + }, + expectedValues: map[authFieldKey]string{ + testField1: testValue1, + testField2: testValue2, + }, + }, + { + description: "overlapping assignments", + valueAssignments: []valueAssignment{ + { + key: testField1, + value: testValue1, + }, + { + key: testField2, + value: testValue2, + }, + { + key: testField1, + value: testValue3, + }, + }, + expectedValues: map[authFieldKey]string{ + testField1: testValue3, + testField2: testValue2, + }, + }, + { + description: "overlapping assignments w/ keyring failing", + keyringFails: true, + valueAssignments: []valueAssignment{ + { + key: testField1, + value: testValue1, + }, + { + key: testField2, + value: testValue2, + }, + { + key: testField1, + value: testValue3, + }, + }, + expectedValues: map[authFieldKey]string{ + testField1: testValue3, + testField2: testValue2, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + if !tt.keyringFails { + keyring.MockInit() + } else { + keyring.MockInitWithError(fmt.Errorf("keyring unavailable for testing")) + } + + for _, assignment := range tt.valueAssignments { + err := SetAuthField(assignment.key, assignment.value) + if err != nil { + t.Fatalf("Failed to set \"%s\" as \"%s\": %v", assignment.key, assignment.value, err) + } + // Check that this value will be checked + if _, ok := tt.expectedValues[assignment.key]; !ok { + t.Fatalf("Value \"%s\" set but not checked. Please add it to 'expectedValues'", assignment.key) + } + } + + for key, valueExpected := range tt.expectedValues { + value, err := GetAuthField(key) + if err != nil { + t.Errorf("Failed to get value of \"%s\": %v", key, err) + continue + } else if value != valueExpected { + t.Errorf("Value of field \"%s\" is wrong: expected \"%s\", got \"%s\"", key, valueExpected, value) + } + + if !tt.keyringFails { + err = deleteAuthFieldInKeyring(key) + if err != nil { + t.Errorf("Post-test cleanup failed: remove field \"%s\" from keyring: %v. Please remove it manually", key, err) + } + } else { + err = deleteAuthFieldInEncodedTextFile(key) + if err != nil { + t.Errorf("Post-test cleanup failed: remove field \"%s\" from text file: %v. Please remove it manually", key, err) + } + } + } + }) + } +} + +func TestSetGetAuthFieldKeyring(t *testing.T) { + var testField1 authFieldKey = "test-field-1" + var testField2 authFieldKey = "test-field-2" + + testValue1 := fmt.Sprintf("value-1-keyring-%s", time.Now().Format(time.RFC3339)) + testValue2 := fmt.Sprintf("value-2-keyring-%s", time.Now().Format(time.RFC3339)) + testValue3 := fmt.Sprintf("value-3-keyring-%s", time.Now().Format(time.RFC3339)) + + type valueAssignment struct { + key authFieldKey + value string + } + + tests := []struct { + description string + valueAssignments []valueAssignment + expectedValues map[authFieldKey]string + }{ + { + description: "simple assignments", + valueAssignments: []valueAssignment{ + { + key: testField1, + value: testValue1, + }, + { + key: testField2, + value: testValue2, + }, + }, + expectedValues: map[authFieldKey]string{ + testField1: testValue1, + testField2: testValue2, + }, + }, + { + description: "overlapping assignments", + valueAssignments: []valueAssignment{ + { + key: testField1, + value: testValue1, + }, + { + key: testField2, + value: testValue2, + }, + { + key: testField1, + value: testValue3, + }, + }, + expectedValues: map[authFieldKey]string{ + testField1: testValue3, + testField2: testValue2, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + keyring.MockInit() + + for _, assignment := range tt.valueAssignments { + err := setAuthFieldInKeyring(assignment.key, assignment.value) + if err != nil { + t.Fatalf("Failed to set \"%s\" as \"%s\": %v", assignment.key, assignment.value, err) + } + // Check that this value will be checked + if _, ok := tt.expectedValues[assignment.key]; !ok { + t.Fatalf("Value \"%s\" set but not checked. Please add it to 'expectedValues'", assignment.key) + } + } + + for key, valueExpected := range tt.expectedValues { + value, err := getAuthFieldFromKeyring(key) + if err != nil { + t.Errorf("Failed to get value of \"%s\": %v", key, err) + continue + } else if value != valueExpected { + t.Errorf("Value of field \"%s\" is wrong: expected \"%s\", got \"%s\"", key, valueExpected, value) + } + + err = deleteAuthFieldInKeyring(key) + if err != nil { + t.Errorf("Post-test cleanup failed: remove field \"%s\" from keyring: %v. Please remove it manually", key, err) + } + } + }) + } +} + +func TestSetGetAuthFieldEncodedTextFile(t *testing.T) { + var testField1 authFieldKey = "test-field-1" + var testField2 authFieldKey = "test-field-2" + + testValue1 := fmt.Sprintf("value-1-text-%s", time.Now().Format(time.RFC3339)) + testValue2 := fmt.Sprintf("value-2-text-%s", time.Now().Format(time.RFC3339)) + testValue3 := fmt.Sprintf("value-3-text-%s", time.Now().Format(time.RFC3339)) + + type valueAssignment struct { + key authFieldKey + value string + } + + tests := []struct { + description string + valueAssignments []valueAssignment + expectedValues map[authFieldKey]string + }{ + { + description: "simple assignments", + valueAssignments: []valueAssignment{ + { + key: testField1, + value: testValue1, + }, + { + key: testField2, + value: testValue2, + }, + }, + expectedValues: map[authFieldKey]string{ + testField1: testValue1, + testField2: testValue2, + }, + }, + { + description: "overlapping assignments", + valueAssignments: []valueAssignment{ + { + key: testField1, + value: testValue1, + }, + { + key: testField2, + value: testValue2, + }, + { + key: testField1, + value: testValue3, + }, + }, + expectedValues: map[authFieldKey]string{ + testField1: testValue3, + testField2: testValue2, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + for _, assignment := range tt.valueAssignments { + err := setAuthFieldInEncodedTextFile(assignment.key, assignment.value) + if err != nil { + t.Fatalf("Failed to set \"%s\" as \"%s\": %v", assignment.key, assignment.value, err) + } + // Check that this value will be checked + if _, ok := tt.expectedValues[assignment.key]; !ok { + t.Fatalf("Value \"%s\" set but not checked. Please add it to 'expectedValues'", assignment.key) + } + } + + for key, valueExpected := range tt.expectedValues { + value, err := getAuthFieldFromEncodedTextFile(key) + if err != nil { + t.Errorf("Failed to get value of \"%s\": %v", key, err) + continue + } else if value != valueExpected { + t.Errorf("Value of field \"%s\" is wrong: expected \"%s\", got \"%s\"", key, valueExpected, value) + } + + err = deleteAuthFieldInEncodedTextFile(key) + if err != nil { + t.Errorf("Post-test cleanup failed: remove field \"%s\" from text file: %v. Please remove it manually", key, err) + } + } + }) + } +} + +func deleteAuthFieldInKeyring(key authFieldKey) error { + return keyring.Delete(keyringService, string(key)) +} + +func deleteAuthFieldInEncodedTextFile(key authFieldKey) error { + err := createEncodedTextFile() + if err != nil { + return err + } + + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("get home dir: %w", err) + } + textFileDir := filepath.Join(homeDir, textFileFolderName) + textFilePath := filepath.Join(textFileDir, textFileName) + + contentEncoded, err := os.ReadFile(textFilePath) + if err != nil { + return fmt.Errorf("read file: %w", err) + } + contentBytes, err := base64.StdEncoding.DecodeString(string(contentEncoded)) + if err != nil { + return fmt.Errorf("decode file: %w", err) + } + content := map[authFieldKey]string{} + err = json.Unmarshal(contentBytes, &content) + if err != nil { + return fmt.Errorf("unmarshal file: %w", err) + } + + delete(content, key) + + contentBytes, err = json.Marshal(content) + if err != nil { + return fmt.Errorf("marshal file: %w", err) + } + contentEncoded = []byte(base64.StdEncoding.EncodeToString(contentBytes)) + err = os.WriteFile(textFilePath, contentEncoded, 0o600) + if err != nil { + return fmt.Errorf("write file: %w", err) + } + return nil +} diff --git a/internal/pkg/auth/user_login.go b/internal/pkg/auth/user_login.go new file mode 100644 index 00000000..2be61c24 --- /dev/null +++ b/internal/pkg/auth/user_login.go @@ -0,0 +1,261 @@ +package auth + +import ( + "encoding/json" + "errors" + "fmt" + "html/template" + "io" + "net" + "net/http" + "net/url" + "os" + "os/exec" + "runtime" + "strings" + "time" + + "golang.org/x/oauth2" +) + +const ( + authDomain = "auth.01.idp.eu01.stackit.cloud/oauth" + clientId = "stackit-cli-client-id" + redirectURL = "http://localhost:8000" + loginSuccessPath = "/login-successful" + stackitLandingPage = "https://www.stackit.de" + loginSuccessHtmlPageFilePath = "internal/pkg/auth/login-successful.html" + emailPlaceholder = "$USER_EMAIL" +) + +type User struct { + Email string +} + +// AuthorizeUser implements the PKCE OAuth2 flow. +func AuthorizeUser() error { + conf := &oauth2.Config{ + ClientID: clientId, + Endpoint: oauth2.Endpoint{ + AuthURL: fmt.Sprintf("https://%s/authorize", authDomain), + }, + Scopes: []string{"openid"}, + RedirectURL: redirectURL, + } + + // Initialize the code verifier + codeVerifier := oauth2.GenerateVerifier() + + // Construct the authorization URL + authorizationURL := conf.AuthCodeURL("", oauth2.S256ChallengeOption(codeVerifier)) + + // Start a web server to listen on a callback URL + mux := http.NewServeMux() + server := &http.Server{ + Addr: redirectURL, + Handler: mux, + ReadHeaderTimeout: 10 * time.Second, + } + + // Define a handler that will get the authorization code, call the token endpoint, and close the HTTP server + var errServer error + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + // Close the server only if there was an error + // Otherwise, it will redirect to the succesfull login page + defer func() { + if errServer != nil { + fmt.Println(errServer) + cleanup(server) + } + }() + + // Get the authorization code + code := r.URL.Query().Get("code") + if code == "" { + errServer = fmt.Errorf("could not find 'code' URL parameter") + return + } + + // Trade the authorization code and the code verifier for access and refresh tokens + accessToken, refreshToken, err := getUserAccessAndRefreshTokens(authDomain, clientId, codeVerifier, code, redirectURL) + if err != nil { + errServer = fmt.Errorf("retrieve tokens: %w", err) + return + } + + sessionExpiresAtUnix, err := getStartingSessionExpiresAtUnix() + if err != nil { + errServer = fmt.Errorf("compute session expiration timestamp: %w", err) + return + } + + err = SetAuthFlow(AUTH_FLOW_USER_TOKEN) + if err != nil { + errServer = fmt.Errorf("set auth flow type: %w", err) + return + } + + email, err := getEmailFromToken(accessToken) + if err != nil { + errServer = fmt.Errorf("get email from access token: %w", err) + return + } + + authFields := map[authFieldKey]string{ + SESSION_EXPIRES_AT_UNIX: sessionExpiresAtUnix, + ACCESS_TOKEN: accessToken, + REFRESH_TOKEN: refreshToken, + USER_EMAIL: email, + } + err = SetAuthFieldMap(authFields) + if err != nil { + errServer = fmt.Errorf("set in auth storage: %w", err) + return + } + + // Redirect the user to the successful login page + loginSuccessURL := redirectURL + loginSuccessPath + http.Redirect(w, r, loginSuccessURL, http.StatusSeeOther) + }) + + mux.HandleFunc(loginSuccessPath, func(w http.ResponseWriter, r *http.Request) { + defer cleanup(server) + + email, err := GetAuthField(USER_EMAIL) + if err != nil { + errServer = fmt.Errorf("read user email: %w", err) + } + + user := User{ + Email: email, + } + + htmlTemplate, err := template.ParseFiles(loginSuccessHtmlPageFilePath) + if err != nil { + errServer = fmt.Errorf("parse html file: %w", err) + } + + err = htmlTemplate.Execute(w, user) + if err != nil { + errServer = fmt.Errorf("render page: %w", err) + } + }) + + // Parse the redirect URL for the port number + u, err := url.Parse(redirectURL) + if err != nil { + return fmt.Errorf("parse redirect URL: %w", err) + } + + // Set up a listener on the redirect port + port := fmt.Sprintf(":%s", u.Port()) + l, err := net.Listen("tcp", port) + if err != nil { + return fmt.Errorf("listen to port %s: %w", port, err) + } + + // Open a browser window to the authorizationURL + err = openBrowser(authorizationURL) + if err != nil { + return fmt.Errorf("open browser to URL %s: %w", authorizationURL, err) + } + + // Start the blocking web server loop + // It will exit when the handlers get fired and call server.Close() + err = server.Serve(l) + if !errors.Is(err, http.ErrServerClosed) { + return fmt.Errorf("server for PKCE flow closed unexpectedly: %w", err) + } + + // Check if there was an error in the HTTP server + if errServer != nil { + return fmt.Errorf("PKCE flow: %w", errServer) + } + + return nil +} + +// getUserAccessAndRefreshTokens trades the authorization code retrieved from the first OAuth2 leg for an access token and a refresh token +func getUserAccessAndRefreshTokens(authDomain, clientID, codeVerifier, authorizationCode, callbackURL string) (accessToken, refreshToken string, err error) { + // Set the authUrl and form-encoded data for the POST to the access token endpoint + authUrl := fmt.Sprintf("https://%s/token", authDomain) + data := fmt.Sprintf( + "grant_type=authorization_code&client_id=%s"+ + "&code_verifier=%s"+ + "&code=%s"+ + "&redirect_uri=%s", + clientID, codeVerifier, authorizationCode, callbackURL) + payload := strings.NewReader(data) + + // Create the request and execute it + req, _ := http.NewRequest("POST", authUrl, payload) + req.Header.Add("content-type", "application/x-www-form-urlencoded") + httpClient := &http.Client{} + res, err := httpClient.Do(req) + if err != nil { + return "", "", fmt.Errorf("call access token endpoint: %w", err) + } + + // Process the response + defer func() { + closeErr := res.Body.Close() + if closeErr != nil { + err = fmt.Errorf("close response body: %w", closeErr) + } + }() + body, err := io.ReadAll(res.Body) + if err != nil { + return "", "", fmt.Errorf("read response body: %w", err) + } + + // Unmarshal the json into a string map + responseData := struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + }{} + err = json.Unmarshal(body, &responseData) + if err != nil { + return "", "", fmt.Errorf("unmarshal response: %w", err) + } + if responseData.AccessToken == "" { + return "", "", fmt.Errorf("found no access token") + } + if responseData.RefreshToken == "" { + return "", "", fmt.Errorf("found no refresh token") + } + + return responseData.AccessToken, responseData.RefreshToken, nil +} + +// Cleanup closes the HTTP server +func cleanup(server *http.Server) { + // We run this as a goroutine so that this function falls through and + // the socket to the browser gets flushed/closed before the server goes away + go func() { + _ = server.Close() + }() +} + +func openBrowser(pageUrl string) error { + var err error + switch runtime.GOOS { + case "linux": + // We need to use the windows way on WSL, otherwise we do not pass query + // parameters correctly. https://github.com/microsoft/WSL/issues/3832 + if _, ok := os.LookupEnv("WSL_DISTRO_NAME"); !ok { + err = exec.Command("xdg-open", pageUrl).Start() + break + } + fallthrough + case "windows": + err = exec.Command("rundll32.exe", "url.dll,FileProtocolHandler", pageUrl).Start() + case "darwin": + err = exec.Command("open", pageUrl).Start() + default: + err = fmt.Errorf("unsupported platform") + } + if err != nil { + return err + } + return nil +} diff --git a/internal/pkg/auth/user_token_flow.go b/internal/pkg/auth/user_token_flow.go new file mode 100644 index 00000000..03aced9c --- /dev/null +++ b/internal/pkg/auth/user_token_flow.go @@ -0,0 +1,203 @@ +package auth + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/core/clients" +) + +type userTokenFlow struct { + cmd *cobra.Command + reauthorizeUserRoutine func() error // Called if the user needs to login again + client *http.Client + authFlow AuthFlow + accessToken string + refreshToken string +} + +// Ensure the implementation satisfies the expected interface +var _ http.RoundTripper = &userTokenFlow{} + +// Returns a round tripper that adds authentication according to the user token flow +func UserTokenFlow(cmd *cobra.Command) *userTokenFlow { + return &userTokenFlow{ + cmd: cmd, + reauthorizeUserRoutine: AuthorizeUser, + client: &http.Client{}, + } +} + +func (utf *userTokenFlow) RoundTrip(req *http.Request) (*http.Response, error) { + err := loadVarsFromStorage(utf) + if err != nil { + return nil, err + } + if utf.authFlow != AUTH_FLOW_USER_TOKEN { + return nil, fmt.Errorf("auth flow is not user token") + } + + accessTokenValid := false + if accessTokenExpired, err := tokenExpired(utf.accessToken); err != nil { + return nil, fmt.Errorf("check if access token has expired: %w", err) + } else if !accessTokenExpired { + accessTokenValid = true + } else if refreshTokenExpired, err := tokenExpired(utf.refreshToken); err != nil { + return nil, fmt.Errorf("check if refresh token has expired: %w", err) + } else if !refreshTokenExpired { + err = refreshTokens(utf) + if err == nil { + accessTokenValid = true + } + } + + if !accessTokenValid { + utf.cmd.Println("Session expired, logging in again...") + err = reauthenticateUser(utf) + if err != nil { + return nil, fmt.Errorf("reauthenticate user: %w", err) + } + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", utf.accessToken)) + return clients.Do(utf.client, req, nil) +} + +func loadVarsFromStorage(utf *userTokenFlow) error { + authFlow, err := GetAuthFlow() + if err != nil { + return fmt.Errorf("get auth flow type: %w", err) + } + authFields := map[authFieldKey]string{ + ACCESS_TOKEN: "", + REFRESH_TOKEN: "", + } + err = GetAuthFieldMap(authFields) + if err != nil { + return fmt.Errorf("get tokens from auth storage: %w", err) + } + + utf.authFlow = authFlow + utf.accessToken = authFields[ACCESS_TOKEN] + utf.refreshToken = authFields[REFRESH_TOKEN] + return nil +} + +func reauthenticateUser(utf *userTokenFlow) error { + err := utf.reauthorizeUserRoutine() + if err != nil { + return fmt.Errorf("authenticate user: %w", err) + } + err = loadVarsFromStorage(utf) + if err != nil { + return fmt.Errorf("load auth vars after user authentication: %w", err) + } + if utf.authFlow != AUTH_FLOW_USER_TOKEN { + return fmt.Errorf("auth flow is not user token") + } + return nil +} + +func tokenExpired(token string) (bool, error) { + // We can safely use ParseUnverified because we are not authenticating the user at this point. + // We're just checking the expiration time + tokenParsed, _, err := jwt.NewParser().ParseUnverified(token, &jwt.RegisteredClaims{}) + if err != nil { + return false, fmt.Errorf("parse access token: %w", err) + } + expirationTimestampNumeric, err := tokenParsed.Claims.GetExpirationTime() + if err != nil { + return false, fmt.Errorf("get expiration timestamp from access token: %w", err) + } + expirationTimestamp := expirationTimestampNumeric.Time + now := time.Now() + return now.After(expirationTimestamp), nil +} + +// Refresh access and refresh tokens using a valid refresh token +func refreshTokens(utf *userTokenFlow) (err error) { + req, err := buildRequestToRefreshTokens(utf) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + + resp, err := utf.client.Do(req) + if err != nil { + return fmt.Errorf("call API: %w", err) + } + defer func() { + tempErr := resp.Body.Close() + if tempErr != nil { + err = fmt.Errorf("close response body: %w", tempErr) + } + }() + + accessToken, refreshToken, err := parseRefreshTokensResponse(resp) + if err != nil { + return fmt.Errorf("parse API response: %w", err) + } + err = SetAuthFieldMap(map[authFieldKey]string{ + ACCESS_TOKEN: accessToken, + REFRESH_TOKEN: refreshToken, + }) + if err != nil { + return fmt.Errorf("set refreshed tokens in auth storage: %w", err) + } + utf.accessToken = accessToken + utf.refreshToken = refreshToken + return nil +} + +func buildRequestToRefreshTokens(utf *userTokenFlow) (*http.Request, error) { + req, err := http.NewRequest( + http.MethodPost, + fmt.Sprintf("https://%s/token", authDomain), + http.NoBody, + ) + if err != nil { + return nil, err + } + reqQuery := url.Values{} + reqQuery.Set("grant_type", "refresh_token") + reqQuery.Set("client_id", clientId) + reqQuery.Set("refresh_token", utf.refreshToken) + reqQuery.Set("token_format", "jwt") + req.URL.RawQuery = reqQuery.Encode() + + // without this header, the API returns error "An Authentication object was not found in the SecurityContext" + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + return req, nil +} + +func parseRefreshTokensResponse(resp *http.Response) (accessToken, refreshToken string, err error) { + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", "", fmt.Errorf("read body: %w", err) + } + if resp.StatusCode != http.StatusOK { + return "", "", fmt.Errorf("non-OK %d status: %s", resp.StatusCode, string(respBody)) + } + + respContent := struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + }{} + err = json.Unmarshal(respBody, &respContent) + if err != nil { + return "", "", fmt.Errorf("unmarshal body: %w", err) + } + if respContent.AccessToken == "" { + return "", "", fmt.Errorf("no access token found") + } + if respContent.RefreshToken == "" { + return "", "", fmt.Errorf("refresh token found") + } + return respContent.AccessToken, respContent.RefreshToken, nil +} diff --git a/internal/pkg/auth/user_token_flow_test.go b/internal/pkg/auth/user_token_flow_test.go new file mode 100644 index 00000000..34192c07 --- /dev/null +++ b/internal/pkg/auth/user_token_flow_test.go @@ -0,0 +1,376 @@ +package auth + +import ( + "bytes" + "fmt" + "io" + "net/http" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/spf13/cobra" + "github.com/zalando/go-keyring" +) + +type clientTransport struct { + t *testing.T // May write test errors + requestURL string + refreshTokensFails bool + refreshTokensReturnsNonOKStatus bool + requestSent *bool + tokensRefreshed *bool +} + +func (rt *clientTransport) RoundTrip(req *http.Request) (*http.Response, error) { + reqURL := req.Host + req.URL.Path + if reqURL == rt.requestURL { + return rt.roundTripRequest() + } + if reqURL == fmt.Sprintf("%s/token", authDomain) { + return rt.roundTripRefreshTokens() + } + rt.t.Fatalf("unexpected request to \"%s\"", reqURL) + return nil, fmt.Errorf("unexpected request to \"%s\"", reqURL) +} + +func (rt *clientTransport) roundTripRequest() (*http.Response, error) { + if *rt.requestSent { + rt.t.Errorf("request executed multiple times") + } + *rt.requestSent = true + + resp := &http.Response{ + Status: http.StatusText(http.StatusOK), + StatusCode: http.StatusOK, + } + return resp, nil +} + +func (rt *clientTransport) roundTripRefreshTokens() (*http.Response, error) { + if rt.refreshTokensFails { + return nil, fmt.Errorf("failed") + } + if rt.refreshTokensReturnsNonOKStatus { + resp := &http.Response{ + Status: http.StatusText(http.StatusInternalServerError), + StatusCode: http.StatusInternalServerError, + } + return resp, nil + } + + if *rt.tokensRefreshed { + rt.t.Errorf("tokens refreshed more than once") + } + *rt.tokensRefreshed = true + expirationTimestamp := time.Now().Add(time.Hour) + accessToken, refreshToken, err := createTokens(expirationTimestamp, expirationTimestamp) + if err != nil { + rt.t.Fatalf("refresh token API: failed to create tokens: %v", err) + } + respBody := fmt.Sprintf( + `{ + "access_token": "%s", + "refresh_token": "%s" + }`, + accessToken, + refreshToken, + ) + resp := &http.Response{ + Status: http.StatusText(http.StatusOK), + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader([]byte(respBody))), + } + return resp, nil +} + +type authorizeUserContext struct { + t *testing.T // May write test errors + authorizeUserFails bool + authorizeUserCalled *bool + tokensRefreshed *bool +} + +func reauthorizeUser(auCtx *authorizeUserContext) error { + if *auCtx.authorizeUserCalled { + auCtx.t.Errorf("user authenticated more than once") + } + *auCtx.authorizeUserCalled = true + + if auCtx.authorizeUserFails { + return fmt.Errorf("failed") + } + + if *auCtx.tokensRefreshed { + auCtx.t.Errorf("tokens refreshed more than once") + } + *auCtx.tokensRefreshed = true + err := setAuthStorage( + time.Now().Add(time.Hour), + time.Now().Add(time.Hour), + true, + true, + ) + if err != nil { + auCtx.t.Fatalf("failed to set auth vars in authorize user: %v", err) + } + return nil +} + +func TestRoundTrip(t *testing.T) { + tests := []struct { + desc string + // Test settings + accessTokenExpiresAt time.Time + refreshTokenExpiresAt time.Time + authStorageFails bool + accessTokenInvalid bool + refreshTokenInvalid bool + authorizeUserFails bool + refreshTokensFails bool + refreshTokensReturnsNonOKStatus bool + // Expected outcome settings + isValid bool + expectedReautorizeUserCalled bool + expectedTokensRefreshed bool + }{ + { + desc: "happy path", + accessTokenExpiresAt: time.Now().Add(time.Hour), + refreshTokenExpiresAt: time.Now().Add(time.Hour), + isValid: true, + expectedReautorizeUserCalled: false, + expectedTokensRefreshed: false, + }, + { + desc: "use access token", + accessTokenExpiresAt: time.Now().Add(time.Hour), + refreshTokenExpiresAt: time.Now().Add(-time.Hour), + isValid: true, + expectedReautorizeUserCalled: false, + expectedTokensRefreshed: false, + }, + { + desc: "use refresh token", + accessTokenExpiresAt: time.Now().Add(-time.Hour), + refreshTokenExpiresAt: time.Now().Add(time.Hour), + isValid: true, + expectedReautorizeUserCalled: false, + expectedTokensRefreshed: true, + }, + { + desc: "tokens expired", + accessTokenExpiresAt: time.Now().Add(-time.Hour), + refreshTokenExpiresAt: time.Now().Add(-time.Hour), + isValid: true, + expectedReautorizeUserCalled: true, + expectedTokensRefreshed: true, + }, + { + desc: "auth storage fails", + accessTokenExpiresAt: time.Now().Add(time.Hour), + refreshTokenExpiresAt: time.Now().Add(time.Hour), + authStorageFails: true, + isValid: false, + expectedReautorizeUserCalled: false, + expectedTokensRefreshed: false, + }, + { + desc: "access token invalid", + accessTokenExpiresAt: time.Now().Add(time.Hour), + refreshTokenExpiresAt: time.Now().Add(time.Hour), + accessTokenInvalid: true, + isValid: false, + expectedReautorizeUserCalled: false, + expectedTokensRefreshed: false, + }, + { + desc: "refresh token invalid", + accessTokenExpiresAt: time.Now().Add(-time.Hour), + refreshTokenExpiresAt: time.Now().Add(time.Hour), + refreshTokenInvalid: true, + isValid: false, + expectedReautorizeUserCalled: false, + expectedTokensRefreshed: false, + }, + { + desc: "refresh token invalid but unused", + accessTokenExpiresAt: time.Now().Add(time.Hour), + refreshTokenExpiresAt: time.Now().Add(time.Hour), + refreshTokenInvalid: true, + isValid: true, + expectedReautorizeUserCalled: false, + expectedTokensRefreshed: false, + }, + { + desc: "authorize user fails", + accessTokenExpiresAt: time.Now().Add(-time.Hour), + refreshTokenExpiresAt: time.Now().Add(-time.Hour), + authorizeUserFails: true, + isValid: false, + expectedReautorizeUserCalled: true, + expectedTokensRefreshed: false, + }, + { + desc: "refresh tokens fails", + accessTokenExpiresAt: time.Now().Add(-time.Hour), + refreshTokenExpiresAt: time.Now().Add(time.Hour), + refreshTokensFails: true, + isValid: true, + expectedReautorizeUserCalled: true, + expectedTokensRefreshed: true, + }, + { + desc: "refresh tokens non OK", + accessTokenExpiresAt: time.Now().Add(-time.Hour), + refreshTokenExpiresAt: time.Now().Add(time.Hour), + refreshTokensReturnsNonOKStatus: true, + isValid: true, + expectedReautorizeUserCalled: true, + expectedTokensRefreshed: true, + }, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + // Setup auth storage + if tt.authStorageFails { + keyring.MockInitWithError(fmt.Errorf("failed")) + } else { + keyring.MockInit() + err := setAuthStorage( + tt.accessTokenExpiresAt, + tt.refreshTokenExpiresAt, + tt.accessTokenInvalid, + tt.refreshTokenInvalid, + ) + if err != nil { + t.Fatalf("failed to set auth storage: %v", err) + } + } + + // Setup transport and authorizeUser + requestSent := false + authorizeUserCalled := false + tokensRefreshed := false + refreshTokensTransport := &clientTransport{ + t: t, + requestURL: "request/url", + refreshTokensFails: tt.refreshTokensFails, + refreshTokensReturnsNonOKStatus: tt.refreshTokensReturnsNonOKStatus, + requestSent: &requestSent, + tokensRefreshed: &tokensRefreshed, + } + client := &http.Client{ + Transport: refreshTokensTransport, + } + authorizeUserContext := &authorizeUserContext{ + t: t, + authorizeUserFails: tt.authorizeUserFails, + authorizeUserCalled: &authorizeUserCalled, + tokensRefreshed: &tokensRefreshed, + } + authorizeUserRoutine := func() error { + return reauthorizeUser(authorizeUserContext) + } + + cmd := &cobra.Command{} + cmd.SetOut(io.Discard) // Suppresses console prints + + // Test RoundTripper + rt := userTokenFlow{ + cmd: cmd, + reauthorizeUserRoutine: authorizeUserRoutine, + client: client, + } + req, err := http.NewRequest(http.MethodGet, "request/url", http.NoBody) + if err != nil { + t.Fatalf("failed to create request: %v", err) + } + + resp, err := rt.RoundTrip(req) + if err == nil { + defer func() { + tempErr := resp.Body.Close() + if tempErr != nil { + t.Fatalf("failed to close response body: %v", tempErr) + } + }() + } + + if !tt.isValid && err == nil { + if err == nil { + t.Errorf("should have failed") + } + if requestSent { + t.Errorf("request was sent") + } + } + if tt.isValid && err != nil { + if err != nil { + t.Errorf("shouldn't have failed: %v", err) + } + if !requestSent { + t.Errorf("request wasn't sent") + } + } + if authorizeUserCalled && !tt.expectedReautorizeUserCalled { + t.Errorf("reauthorizeUser was called") + } + if !authorizeUserCalled && tt.expectedReautorizeUserCalled { + t.Errorf("reauthorizeUser wasn't called") + } + if tokensRefreshed && !tt.expectedTokensRefreshed { + t.Errorf("tokens were refreshed") + } + if !tokensRefreshed && tt.expectedTokensRefreshed { + t.Errorf("tokens weren't refreshed") + } + }) + } +} + +// Generates access and refresh tokens with the expiration timestamp provided, then sets the auth fields in storage appropriately +func setAuthStorage(accessTokenExpiresAt, refreshTokenExpiresAt time.Time, accessTokenInvalid, refreshTokenInvalid bool) error { + accessToken, refreshToken, err := createTokens(accessTokenExpiresAt, refreshTokenExpiresAt) + if err != nil { + return fmt.Errorf("create tokens: %w", err) + } + if accessTokenInvalid { + accessToken = "foo.bar.baz" //nolint:gosec // Hardcoded bad credentials + } + if refreshTokenInvalid { + refreshToken = "foo.bar.baz" //nolint:gosec // Hardcoded bad credentials + } + + err = SetAuthFlow(AUTH_FLOW_USER_TOKEN) + if err != nil { + return fmt.Errorf("set auth flow type: %w", err) + } + err = SetAuthFieldMap(map[authFieldKey]string{ + ACCESS_TOKEN: accessToken, + REFRESH_TOKEN: refreshToken, + }) + if err != nil { + return fmt.Errorf("set refreshed tokens in auth storage: %w", err) + } + return nil +} + +func createTokens(accessTokenExpiresAt, refreshTokenExpiresAt time.Time) (accessToken, refreshToken string, err error) { + accessToken, err = jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(accessTokenExpiresAt), + }).SignedString([]byte("test")) + if err != nil { + return "", "", fmt.Errorf("create access token: %w", err) + } + + refreshToken, err = jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(refreshTokenExpiresAt), + }).SignedString([]byte("test")) + if err != nil { + return "", "", fmt.Errorf("create refresh token: %w", err) + } + + return accessToken, refreshToken, nil +} diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go new file mode 100644 index 00000000..2f122137 --- /dev/null +++ b/internal/pkg/config/config.go @@ -0,0 +1,118 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// Supported config keys +const ( + AsyncKey = "async" + OutputFormatKey = "output_format" + ProjectIdKey = "project_id" + SessionTimeLimitKey = "session_time_limit" + + DNSCustomEndpointKey = "dns_custom_endpoint" + MembershipCustomEndpointKey = "membership_custom_endpoint" + MongoDBFlexCustomEndpointKey = "mongodbflex_custom_endpoint" + ServiceAccountCustomEndpointKey = "service_account_custom_endpoint" + SKECustomEndpointKey = "ske_custom_endpoint" + ResourceManagerEndpointKey = "resource_manager_custom_endpoint" + + AsyncDefault = "false" + SessionTimeLimitDefault = "2h" +) + +// Backend config keys +const ( + configFolder = ".stackit" + configFileName = "cli-config" + configFileExtension = "json" + ProjectNameKey = "project_name" +) + +var ConfigKeys = []string{ + AsyncKey, + OutputFormatKey, + ProjectIdKey, + SessionTimeLimitKey, + DNSCustomEndpointKey, + MembershipCustomEndpointKey, + MongoDBFlexCustomEndpointKey, + ServiceAccountCustomEndpointKey, + SKECustomEndpointKey, + ResourceManagerEndpointKey, +} + +func InitConfig() { + home, err := os.UserHomeDir() + cobra.CheckErr(err) + configFolderPath := filepath.Join(home, configFolder) + configFilePath := filepath.Join(configFolderPath, fmt.Sprintf("%s.%s", configFileName, configFileExtension)) + + viper.SetConfigName(configFileName) + viper.SetConfigType(configFileExtension) + viper.AddConfigPath(configFolderPath) + + err = createFolderIfNotExists(configFolderPath) + cobra.CheckErr(err) + err = createFileIfNotExists(configFilePath) + cobra.CheckErr(err) + + err = viper.ReadInConfig() + cobra.CheckErr(err) + setConfigDefaults() + + err = viper.WriteConfigAs(configFilePath) + cobra.CheckErr(err) + + // Needs to be done after WriteConfigAs, otherwise it would write + // the environment variables to the config file + viper.AutomaticEnv() + viper.SetEnvPrefix("stackit") +} + +func createFolderIfNotExists(folderPath string) error { + _, err := os.Stat(folderPath) + if os.IsNotExist(err) { + err := os.MkdirAll(folderPath, os.ModePerm) + if err != nil { + return err + } + } else if err != nil { + return err + } + return nil +} + +func createFileIfNotExists(filePath string) error { + _, err := os.Stat(filePath) + if os.IsNotExist(err) { + err := viper.SafeWriteConfigAs(filePath) + if err != nil { + return err + } + } else if err != nil { + return err + } + return nil +} + +// All config keys should be set to a default value so that they can be set as an environment variable +// They will not show in the config list if they are empty +func setConfigDefaults() { + viper.SetDefault(AsyncKey, AsyncDefault) + viper.SetDefault(OutputFormatKey, "") + viper.SetDefault(ProjectIdKey, "") + viper.SetDefault(SessionTimeLimitKey, SessionTimeLimitDefault) + viper.SetDefault(DNSCustomEndpointKey, "") + viper.SetDefault(MembershipCustomEndpointKey, "") + viper.SetDefault(MongoDBFlexCustomEndpointKey, "") + viper.SetDefault(ServiceAccountCustomEndpointKey, "") + viper.SetDefault(SKECustomEndpointKey, "") + viper.SetDefault(ResourceManagerEndpointKey, "") +} diff --git a/internal/pkg/confirm/confirm.go b/internal/pkg/confirm/confirm.go new file mode 100644 index 00000000..99a2f2f9 --- /dev/null +++ b/internal/pkg/confirm/confirm.go @@ -0,0 +1,36 @@ +package confirm + +import ( + "bufio" + "errors" + "fmt" + "strings" + + "github.com/spf13/cobra" +) + +var errAborted = errors.New("operation aborted") + +// Prompts the user for confirmation. +// +// Returns nil only if the user (explicitly) answers positive. +// Returns ErrAborted if the user answers negative. +func PromptForConfirmation(cmd *cobra.Command, prompt string) error { + question := fmt.Sprintf("%s [y/N] ", prompt) + reader := bufio.NewReader(cmd.InOrStdin()) + for i := 0; i < 3; i++ { + cmd.Print(question) + answer, err := reader.ReadString('\n') + if err != nil { + continue + } + answer = strings.ToLower(strings.TrimSpace(answer)) + if answer == "y" || answer == "yes" { + return nil + } + if answer == "" || answer == "n" || answer == "no" { + return errAborted + } + } + return fmt.Errorf("max number of wrong inputs") +} diff --git a/internal/pkg/confirm/confirm_test.go b/internal/pkg/confirm/confirm_test.go new file mode 100644 index 00000000..84f79295 --- /dev/null +++ b/internal/pkg/confirm/confirm_test.go @@ -0,0 +1,165 @@ +package confirm + +import ( + "bytes" + "errors" + "io" + "testing" + + "github.com/spf13/cobra" +) + +func TestPromptForConfirmation(t *testing.T) { + tests := []struct { + description string + input string + isValid bool + isAborted bool + }{ + // Note: Some of these inputs have normal spaces, others have tabs + { + description: "yes - simple 1", + input: "y\n", + isValid: true, + }, + { + description: "yes - simple 2", + input: " Y \r\n", + isValid: true, + }, + { + description: "yes - simple 3", + input: " yes\n", + isValid: true, + }, + { + description: "yes - simple 4", + input: "YES\n", + isValid: true, + }, + { + description: "yes - retries 1", + input: "yrs\nyes\n", + isValid: true, + }, + { + description: "yes - retries 2", + input: "foo\nbar \n y\n", + isValid: true, + }, + { + description: "yes - retries 3", + input: "foo\r\nbar \nY \n", + isValid: true, + }, + { + description: "no - simple 1", + input: "n\n", + isValid: false, + isAborted: true, + }, + { + description: "no - simple 2", + input: " N \r\n", + isValid: false, + isAborted: true, + }, + { + description: "no - simple 3", + input: "no\n", + isValid: false, + isAborted: true, + }, + { + description: "no - simple 4", + input: " \n", + isValid: false, + isAborted: true, + }, + { + description: "no - simple 5", + input: " \r\n", + isValid: false, + isAborted: true, + }, + { + description: "no - retries 1", + input: "ni\n no \n", + isValid: false, + isAborted: true, + }, + { + description: "no - retries 2", + input: "foo\nbar\nn\n", + isValid: false, + isAborted: true, + }, + { + description: "no - retries 3", + input: "foo\r\nbar\nN\n", + isValid: false, + isAborted: true, + }, + { + description: "no - retries 4", + input: "m\n \n", + isValid: false, + isAborted: true, + }, + { + description: "no - retries 5", + input: "m\r\n \r\n", + isValid: false, + isAborted: true, + }, + { + description: "max retries 1", + input: "foo\nbar\nbaz\n", + isValid: false, + }, + { + description: "max retries 2", + input: "foo\r\nbar\r\nbaz\r\n", + isValid: false, + }, + { + description: "max retries 3", + input: "foo\nbar\nbaz\ny\n", + isValid: false, + }, + { + description: "no input", + input: "", + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + buffer := &bytes.Buffer{} + _, err := buffer.WriteString(tt.input) + if err != nil { + t.Fatalf("failed to initialize mock input: %v", err) + } + + cmd := &cobra.Command{} + cmd.SetOut(io.Discard) // Suppresses console prints + cmd.SetIn(buffer) + + err = PromptForConfirmation(cmd, "") + + if tt.isValid && err != nil { + t.Errorf("should not have failed: %v", err) + } + if !tt.isValid && err == nil { + t.Errorf("should have failed") + } + if tt.isAborted && !errors.Is(err, errAborted) { + t.Errorf("should have returned aborted error, instead returned: %v", err) + } + if !tt.isAborted && errors.Is(err, errAborted) { + t.Errorf("should not have returned aborted error") + } + }) + } +} diff --git a/internal/pkg/errors/errors.go b/internal/pkg/errors/errors.go new file mode 100644 index 00000000..aa871099 --- /dev/null +++ b/internal/pkg/errors/errors.go @@ -0,0 +1,233 @@ +package errors + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +const ( + MISSING_PROJECT_ID = `the project ID is not currently set. + +It can be set on the command level by re-running your command with the --project-id flag. + +You can configure it for all commands by running: + + $ stackit config set --project-id xxx + +or you can also set it through the environment variable [STACKIT_PROJECT_ID]` + + EMPTY_UPDATE = `please specify at least one field to update. + +Get details on the available flags by re-running your command with the --help flag.` + + FAILED_AUTH = `you are not authenticated. + +You can authenticate as a user by running: + $ stackit auth login + +or use a service account by running: + $ stackit auth activate-service-account` + + FAILED_SERVICE_ACCOUNT_ACTIVATION = `could not setup authentication based on the provided service account credentials. +Please double check if they are correctly configured. + +For more details run: + $ stackit auth activate-service-account -h` + + DSA_INVALID_INPUT_PLAN = `the instance plan was not correctly provided. + +Either provide plan-id by running: + $ stackit %[1]s instance %[2]s --project-id xxx --name my-instance --plan-id + +or provide plan-name and version: + $ stackit %[1]s instance %[2]s --project-id xxx --name my-instance --plan-name --version + +For more details on the available plans, run: + $ stackit %[1]s plans` + + DSA_INVALID_PLAN = `the provided instance plan is not valid. + +%s + +For more details on the available plans, run: + $ stackit %s plans` + + DATABASE_INVALID_INPUT_FLAVOR = `the instance flavor was not correctly provided. + +Either provide flavor-id by running: + $ stackit %[1]s instance %[2]s --project-id xxx --flavor-id [flags] + +or provide CPU and RAM: + $ stackit %[1]s instance %[2]s --project-id xxx --cpu --ram [flags] + +For more details on the available flavors, run: + $ stackit %[1]s options --flavors` + + DATABASE_INVALID_FLAVOR = `the provided instance flavor is not valid. + +%s + +For more details on the available flavors, run: + $ stackit %s options --flavors` + + DATABASE_INVALID_STORAGE = `invalid instance storage. + +%[1]s + +For more details on the available storages for the configured flavor (%[3]s), run: + $ stackit %[2]s options --storages --flavor-id %[3]s` + + FLAG_VALIDATION = `the provided flag --%s is invalid: %s` + + ARG_VALIDATION = `the provided argument "%s" is invalid: %s` + + ARG_UNKNOWN = `unknown argument %q` + + ARG_MISSING = `missing argument %q` + + SINGLE_ARG_EXPECTED = `expected 1 argument %q, %d were provided` + + SUBCOMMAND_UNKNOWN = `unkwown subcommand %q` + + SUBCOMMAND_MISSING = `missing subcommand` + + USAGE_TIP = `For usage help, run: + $ %s --help` +) + +type ProjectIdError struct{} + +func (e *ProjectIdError) Error() string { + return MISSING_PROJECT_ID +} + +type EmptyUpdateError struct{} + +func (e *EmptyUpdateError) Error() string { + return EMPTY_UPDATE +} + +type AuthError struct{} + +func (e *AuthError) Error() string { + return FAILED_AUTH +} + +type ActivateServiceAccountError struct{} + +func (e *ActivateServiceAccountError) Error() string { + return FAILED_SERVICE_ACCOUNT_ACTIVATION +} + +type DSAInputPlanError struct { + Service string + Operation string +} + +func (e *DSAInputPlanError) Error() string { + return fmt.Sprintf(DSA_INVALID_INPUT_PLAN, e.Service, e.Operation) +} + +type DSAInvalidPlanError struct { + Service string + Details string +} + +func (e *DSAInvalidPlanError) Error() string { + return fmt.Sprintf(DSA_INVALID_PLAN, e.Details, e.Service) +} + +type DatabaseInputFlavorError struct { + Service string + Operation string +} + +func (e *DatabaseInputFlavorError) Error() string { + return fmt.Sprintf(DATABASE_INVALID_INPUT_FLAVOR, e.Service, e.Operation) +} + +type DatabaseInvalidFlavorError struct { + Service string + Details string +} + +func (e *DatabaseInvalidFlavorError) Error() string { + return fmt.Sprintf(DATABASE_INVALID_FLAVOR, e.Details, e.Service) +} + +type DatabaseInvalidStorageError struct { + Service string + Details string + FlavorId string +} + +func (e *DatabaseInvalidStorageError) Error() string { + return fmt.Sprintf(DATABASE_INVALID_STORAGE, e.Details, e.Service, e.FlavorId) +} + +type FlagValidationError struct { + Flag string + Details string +} + +func (e *FlagValidationError) Error() string { + return fmt.Sprintf(FLAG_VALIDATION, e.Flag, e.Details) +} + +type ArgValidationError struct { + Arg string + Details string +} + +func (e *ArgValidationError) Error() string { + return fmt.Sprintf(ARG_VALIDATION, e.Arg, e.Details) +} + +type SingleArgExpectedError struct { + Cmd *cobra.Command + Expected string + Count int +} + +func (e *SingleArgExpectedError) Error() string { + var err error + if e.Count > 1 { + err = fmt.Errorf(SINGLE_ARG_EXPECTED, e.Expected, e.Count) + } else { + err = fmt.Errorf(ARG_MISSING, e.Expected) + } + return AppendUsageTip(err, e.Cmd).Error() +} + +// Used when an unexpected non-flag input (either arg or subcommand) is found +type InputUnknownError struct { + ProvidedInput string + Cmd *cobra.Command +} + +func (e *InputUnknownError) Error() string { + // To decide whether the unexpected input is an arg or a subcommand, we assume that only leaf commands (ie, don't have subcomamnds) take args + var err error + if !e.Cmd.HasSubCommands() { + err = fmt.Errorf(ARG_UNKNOWN, e.ProvidedInput) + } else { + err = fmt.Errorf(SUBCOMMAND_UNKNOWN, e.ProvidedInput) + } + return AppendUsageTip(err, e.Cmd).Error() +} + +type SubcommandMissingError struct { + Cmd *cobra.Command +} + +func (e *SubcommandMissingError) Error() string { + err := fmt.Errorf(SUBCOMMAND_MISSING) + return AppendUsageTip(err, e.Cmd).Error() +} + +// Returns a wrapped error whose message adds a tip on how to check out --help for the command +func AppendUsageTip(err error, cmd *cobra.Command) error { + tip := fmt.Sprintf(USAGE_TIP, cmd.CommandPath()) + return fmt.Errorf("%w.\n\n%s", err, tip) +} diff --git a/internal/pkg/examples/examples.go b/internal/pkg/examples/examples.go new file mode 100644 index 00000000..d9f9e015 --- /dev/null +++ b/internal/pkg/examples/examples.go @@ -0,0 +1,40 @@ +package examples + +import "fmt" + +type Example struct { + Description string + Commands []string +} + +// Creates a new example +func NewExample(description string, commands ...string) Example { + return Example{ + Description: description, + Commands: commands, + } +} + +// Returns the example formatted +func (e *Example) format() string { + formatted := fmt.Sprintf(" %s\n", e.Description) + for i, c := range e.Commands { + formatted += fmt.Sprintf(" %s", c) + if i != len(e.Commands)-1 { + formatted += "\n" + } + } + return formatted +} + +// Builds a list of formatted examples +func Build(examples ...Example) string { + formatted := "" + for i, e := range examples { + formatted += e.format() + if i != len(examples)-1 { + formatted += "\n\n" + } + } + return formatted +} diff --git a/internal/pkg/flags/cidr.go b/internal/pkg/flags/cidr.go new file mode 100644 index 00000000..a9dc881e --- /dev/null +++ b/internal/pkg/flags/cidr.go @@ -0,0 +1,48 @@ +package flags + +import ( + "fmt" + "net" + + "github.com/spf13/pflag" +) + +type cidrFlag struct { + value string +} + +// Ensure the implementation satisfies the expected interface +var _ pflag.Value = &cidrFlag{} + +// CIDRFlag returns a flag which must be a valid CIDR. +func CIDRFlag() *cidrFlag { + return &cidrFlag{} +} + +func (f *cidrFlag) String() string { + return f.value +} + +func (f *cidrFlag) Set(value string) error { + if value == "" { + return fmt.Errorf("value cannot be empty") + } + err := validateCIDR(value) + if err != nil { + return err + } + f.value = value + return nil +} + +func (f *cidrFlag) Type() string { + return "string" +} + +func validateCIDR(value string) error { + _, _, err := net.ParseCIDR(value) + if err != nil { + return fmt.Errorf("parse %s as CIDR: %w", value, err) + } + return nil +} diff --git a/internal/pkg/flags/cidrslice.go b/internal/pkg/flags/cidrslice.go new file mode 100644 index 00000000..d8e9a059 --- /dev/null +++ b/internal/pkg/flags/cidrslice.go @@ -0,0 +1,48 @@ +package flags + +import ( + "fmt" + "strings" + + "github.com/spf13/pflag" +) + +type cidrSliceFlag struct { + value []string +} + +// Ensure the implementation satisfies the expected interface +var _ pflag.Value = &cidrFlag{} + +// CIDRSliceFlag returns a flag which must be a valid CIDR slice. +func CIDRSliceFlag() *cidrSliceFlag { + return &cidrSliceFlag{} +} + +func (f *cidrSliceFlag) String() string { + return "[" + strings.Join(f.value, ",") + "]" +} + +func (f *cidrSliceFlag) Set(value string) error { + if value == "" { + return fmt.Errorf("value cannot be empty") + } + + cidrs := strings.Split(value, ",") + + for i, cidr := range cidrs { + cidrs[i] = strings.TrimSpace(cidr) + + err := validateCIDR(cidrs[i]) + if err != nil { + return err + } + } + + f.value = append(f.value, cidrs...) + return nil +} + +func (f *cidrSliceFlag) Type() string { + return "stringSlice" +} diff --git a/internal/pkg/flags/enum.go b/internal/pkg/flags/enum.go new file mode 100644 index 00000000..7ac3a531 --- /dev/null +++ b/internal/pkg/flags/enum.go @@ -0,0 +1,65 @@ +package flags + +import ( + "fmt" + "strings" + + "github.com/spf13/pflag" +) + +type enumFlag struct { + ignoreCase bool + options []string + value string +} + +// Ensure the implementation satisfies the expected interface +var _ pflag.Value = &enumFlag{} + +// EnumFlag returns a flag which must be one of the given values. +// If ignoreCase is true, flag value is returned in lower case. +func EnumFlag(ignoreCase bool, defaultValue string, options ...string) *enumFlag { + if defaultValue == "" { + return &enumFlag{ignoreCase: ignoreCase, options: options} + } + + validDefault := false + for _, o := range options { + if !ignoreCase && defaultValue == o { + validDefault = true + break + } + if ignoreCase && strings.EqualFold(defaultValue, o) { + validDefault = true + break + } + } + if !validDefault { + panic(fmt.Sprintf("default value %q is not one of %q", defaultValue, options)) + } + + return &enumFlag{ignoreCase: ignoreCase, options: options, value: defaultValue} +} + +func (f *enumFlag) String() string { + return f.value +} + +func (f *enumFlag) Set(value string) error { + for _, o := range f.options { + if !f.ignoreCase && value == o { + f.value = value + return nil + } + if f.ignoreCase && strings.EqualFold(value, o) { + f.value = strings.ToLower(value) + return nil + } + } + + return fmt.Errorf("expected one of %q", f.options) +} + +func (f *enumFlag) Type() string { + return "string" +} diff --git a/internal/pkg/flags/enumslice.go b/internal/pkg/flags/enumslice.go new file mode 100644 index 00000000..4b68c902 --- /dev/null +++ b/internal/pkg/flags/enumslice.go @@ -0,0 +1,76 @@ +package flags + +import ( + "fmt" + "strings" + + "github.com/spf13/pflag" +) + +type enumSliceFlag struct { + ignoreCase bool + options []string + value []string + valueSet bool +} + +// Ensure the implementation satisfies the expected interface +var _ pflag.Value = &enumFlag{} + +// EnumSliceFlag returns a flag which is a slice which values must be one of the given values. +// If ignoreCase is true, values are returned in lower case. +func EnumSliceFlag(ignoreCase bool, defaultValues []string, options ...string) *enumSliceFlag { + f := &enumSliceFlag{ignoreCase: ignoreCase, options: options} + err := f.appendToValue(defaultValues) + if err != nil { + panic(err) + } + return f +} + +func (f *enumSliceFlag) appendToValue(values []string) error { + for _, v := range values { + v = strings.TrimSpace(v) + + foundValid := false + for _, o := range f.options { + if !f.ignoreCase && v == o { + f.value = append(f.value, v) + foundValid = true + break + } else if f.ignoreCase && strings.EqualFold(v, o) { + f.value = append(f.value, strings.ToLower(v)) + foundValid = true + break + } + } + + if !foundValid { + return fmt.Errorf("found value %q, expected one of %q", v, f.options) + } + } + return nil +} + +func (f *enumSliceFlag) String() string { + return "[" + strings.Join(f.value, ",") + "]" +} + +func (f *enumSliceFlag) Set(value string) error { + // If the default value is still set, remove it + // (Since we're going to append the incoming values to f.value) + if !f.valueSet { + f.value = []string{} + f.valueSet = true + } + + if value == "" { + return fmt.Errorf("value cannot be empty") + } + values := strings.Split(value, ",") + return f.appendToValue(values) +} + +func (f *enumSliceFlag) Type() string { + return "stringSlice" +} diff --git a/internal/pkg/flags/flag_to_value.go b/internal/pkg/flags/flag_to_value.go new file mode 100644 index 00000000..15c3beea --- /dev/null +++ b/internal/pkg/flags/flag_to_value.go @@ -0,0 +1,158 @@ +package flags + +import ( + "fmt" + "time" + + "github.com/spf13/cobra" +) + +// Returns the flag's value as a string. +// Returns "" if the flag is not set, if its value can not be converted to string, or if the flag does not exist. +func FlagToStringValue(cmd *cobra.Command, flag string) string { + value, err := cmd.Flags().GetString(flag) + if err != nil { + return "" + } + if cmd.Flag(flag).Changed { + return value + } + return "" +} + +// Returns the flag's value as a bool. +// Returns false if its value can not be converted to bool, or if the flag does not exist. +func FlagToBoolValue(cmd *cobra.Command, flag string) bool { + value, err := cmd.Flags().GetBool(flag) + if err != nil { + return false + } + return value +} + +// Returns the flag's value as a []string. +// Returns nil if the flag is not set, if its value can not be converted to []string, or if the flag does not exist. +func FlagToStringSliceValue(cmd *cobra.Command, flag string) []string { + value, err := cmd.Flags().GetStringSlice(flag) + if err != nil { + return nil + } + if cmd.Flag(flag).Changed { + return value + } + return nil +} + +// Returns a pointer to the flag's value. +// Returns nil if the flag is not set, if its value can not be converted to map[string]string, or if the flag does not exist. +func FlagToStringToStringPointer(cmd *cobra.Command, flag string) *map[string]string { //nolint:gocritic //convenient for setting the SDK payload + value, err := cmd.Flags().GetStringToString(flag) + if err != nil { + return nil + } + if cmd.Flag(flag).Changed { + return &value + } + return nil +} + +// Returns a pointer to the flag's value. +// Returns nil if the flag is not set, if its value can not be converted to int64, or if the flag does not exist. +func FlagToInt64Pointer(cmd *cobra.Command, flag string) *int64 { + value, err := cmd.Flags().GetInt64(flag) + if err != nil { + return nil + } + if cmd.Flag(flag).Changed { + return &value + } + return nil +} + +// Returns a pointer to the flag's value. +// Returns nil if the flag is not set, if its value can not be converted to string, or if the flag does not exist. +func FlagToStringPointer(cmd *cobra.Command, flag string) *string { + value, err := cmd.Flags().GetString(flag) + if err != nil { + return nil + } + if cmd.Flag(flag).Changed { + return &value + } + return nil +} + +// Returns a pointer to the flag's value. +// Returns nil if the flag is not set, if its value can not be converted to []string, or if the flag does not exist. +func FlagToStringSlicePointer(cmd *cobra.Command, flag string) *[]string { + value, err := cmd.Flags().GetStringSlice(flag) + if err != nil { + return nil + } + if cmd.Flag(flag).Changed { + return &value + } + return nil +} + +// Returns a pointer to the flag's value. +// Returns nil if the flag is not set, if its value can not be converted to bool, or if the flag does not exist. +func FlagToBoolPointer(cmd *cobra.Command, flag string) *bool { + value, err := cmd.Flags().GetBool(flag) + if err != nil { + return nil + } + if cmd.Flag(flag).Changed { + return &value + } + return nil +} + +// Returns a pointer to the flag's value. +// Returns nil if the flag is not set, or if the flag does not exist. +// Returns an error if its value can not be converted to a date time with the provided format. +func FlagToDateTimePointer(cmd *cobra.Command, flag, format string) (*time.Time, error) { + value, err := cmd.Flags().GetString(flag) + if err != nil { + return nil, nil + } + + if cmd.Flag(flag).Changed { + dateTimeValue, err := time.Parse(format, value) + if err != nil { + return nil, fmt.Errorf("could not convert to date-time with the format %s", format) + } + return &dateTimeValue, nil + } + return nil, nil +} + +// Returns the int64 value set on the flag. If no value is set, returns the flag's default value. +// Returns 0 if the flag value can not be converted to int64 or if the flag does not exist. +func FlagWithDefaultToInt64Value(cmd *cobra.Command, flag string) int64 { + value, err := cmd.Flags().GetInt64(flag) + if err != nil { + return 0 + } + return value +} + +// Returns the string value set on the flag. If no value is set, returns the flag's default value. +// Returns nil if the flag value can not be converted to string or if the flag does not exist. +func FlagWithDefaultToStringValue(cmd *cobra.Command, flag string) string { + value, err := cmd.Flags().GetString(flag) + if err != nil { + return "" + } + return value +} + +// Returns a pointer to the flag's value. If no value is set, returns the flag's default value. +// Returns nil if the flag value can't be converted to []string or if the flag does not exist. +func FlagWithDefaultToStringSlicePointer(cmd *cobra.Command, flag string) *[]string { + value, err := cmd.Flags().GetStringSlice(flag) + if err != nil { + return nil + } + return &value +} diff --git a/internal/pkg/flags/flags_required.go b/internal/pkg/flags/flags_required.go new file mode 100644 index 00000000..e7aecaa1 --- /dev/null +++ b/internal/pkg/flags/flags_required.go @@ -0,0 +1,14 @@ +package flags + +import "github.com/spf13/cobra" + +// Marks all given flags as required, causing the command to report an error if invoked without them. +func MarkFlagsRequired(cmd *cobra.Command, flags ...string) error { + for _, flag := range flags { + err := cmd.MarkFlagRequired(flag) + if err != nil { + return err + } + } + return nil +} diff --git a/internal/pkg/flags/flags_test.go b/internal/pkg/flags/flags_test.go new file mode 100644 index 00000000..c9d30cdd --- /dev/null +++ b/internal/pkg/flags/flags_test.go @@ -0,0 +1,742 @@ +package flags + +import ( + "fmt" + "reflect" + "strings" + "testing" + + "stackit/internal/pkg/utils" + + "github.com/google/uuid" + "github.com/spf13/cobra" +) + +func TestEnumFlag(t *testing.T) { + options := []string{"foo", "BaR"} + + tests := []struct { + description string + ignoreCase bool + value string + isValid bool + }{ + { + description: "valid", + value: "foo", + isValid: true, + }, + { + description: "empty", + value: "", + isValid: false, + }, + { + description: "invalid 1", + value: "ba", + isValid: false, + }, + { + description: "invalid 2", + value: "foo ", + isValid: false, + }, + { + description: "invalid 3", + value: "bar", + isValid: false, + }, + { + description: "ignore case - valid 1", + ignoreCase: true, + value: "foo", + isValid: true, + }, + { + description: "ignore case - valid 2", + ignoreCase: true, + value: "fOO", + isValid: true, + }, + { + description: "ignore case - valid 3", + ignoreCase: true, + value: "bar", + isValid: true, + }, + { + description: "ignore case - invalid 1", + ignoreCase: true, + value: "ba", + isValid: false, + }, + { + description: "ignore case - invalid 2", + ignoreCase: true, + value: "foo ", + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + flag := EnumFlag(tt.ignoreCase, "", options...) + cmd := &cobra.Command{ + Use: "test", + RunE: func(cmd *cobra.Command, args []string) error { + return nil + }, + } + cmd.Flags().Var(flag, "test-flag", "test") + + err := cmd.Flags().Set("test-flag", tt.value) + + if !tt.isValid && err == nil { + t.Fatalf("did not fail on invalid input") + } + if !tt.isValid { + return + } + + if err != nil { + t.Fatalf("failed on valid input: %v", err) + } + value := FlagToStringValue(cmd, "test-flag") + if !tt.ignoreCase && value != tt.value { + t.Fatalf("flag did not return set value") + } + if tt.ignoreCase && !strings.EqualFold(value, tt.value) { + t.Fatalf("flag did not return set value") + } + }) + } +} + +func TestEnumSliceFlag(t *testing.T) { + validOption1 := "foo" + validOption2 := "BaR" + validOption3 := "baz" + + validOption2Lower := strings.ToLower(validOption2) + + options := []string{validOption1, validOption2, validOption3} + + tests := []struct { + description string + ignoreCase bool + defaultValue []string + value1 *string + value2 *string + expectedValue []string + isValid bool + }{ + { + description: "valid two single values", + value1: utils.Ptr(validOption1), + value2: utils.Ptr(validOption2), + expectedValue: []string{validOption1, validOption2}, + isValid: true, + }, + { + description: "valid list value", + value1: utils.Ptr(fmt.Sprintf("%s,%s", validOption1, validOption2)), + expectedValue: []string{validOption1, validOption2}, + isValid: true, + }, + { + description: "valid list value and single value", + value1: utils.Ptr(fmt.Sprintf("%s,%s", validOption1, validOption2)), + value2: utils.Ptr(validOption3), + expectedValue: []string{validOption1, validOption2, validOption3}, + isValid: true, + }, + { + description: "valid two list values", + value1: utils.Ptr(fmt.Sprintf("%s,%s", validOption1, validOption2)), + value2: utils.Ptr(fmt.Sprintf("%s,%s", validOption2, validOption3)), + expectedValue: []string{validOption1, validOption2, validOption2, validOption3}, + isValid: true, + }, + { + description: "invalid value", + value1: utils.Ptr("invalid-value"), + value2: utils.Ptr(validOption1), + isValid: false, + }, + { + description: "invalid value in list", + value1: utils.Ptr(fmt.Sprintf("invalid-value,%s", validOption1)), + isValid: false, + }, + { + description: "invalid empty value", + value1: utils.Ptr(""), + isValid: false, + }, + { + description: "invalid empty value in list", + value1: utils.Ptr(fmt.Sprintf(",%s", validOption1)), + isValid: false, + }, + { + description: "no values", + expectedValue: []string{}, + isValid: true, + }, + { + description: "ignore case - valid single value", + value1: utils.Ptr(validOption2Lower), + ignoreCase: true, + expectedValue: []string{validOption2Lower}, + isValid: true, + }, + { + description: "ignore case - valid in list", + value1: utils.Ptr(fmt.Sprintf("%s,%s", validOption1, validOption2Lower)), + ignoreCase: true, + expectedValue: []string{validOption1, validOption2Lower}, + isValid: true, + }, + { + description: "ignore case - invalid single value", + value1: utils.Ptr("ba"), + ignoreCase: true, + isValid: false, + }, + { + description: "ignore case - invalid in list", + value1: utils.Ptr(fmt.Sprintf("%s,%s", validOption1, "ba")), + ignoreCase: true, + isValid: false, + }, + { + description: "default value", + defaultValue: []string{validOption1, validOption2}, + expectedValue: []string{validOption1, validOption2}, + isValid: true, + }, + { + description: "default value - set value", + defaultValue: []string{validOption1, validOption2}, + value1: utils.Ptr(validOption1), + expectedValue: []string{validOption1}, + isValid: true, + }, + { + description: "ignore case - default value", + defaultValue: []string{validOption2}, + ignoreCase: true, + expectedValue: []string{validOption2Lower}, + isValid: true, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + flag := EnumSliceFlag(tt.ignoreCase, tt.defaultValue, options...) + cmd := &cobra.Command{ + Use: "test", + RunE: func(cmd *cobra.Command, args []string) error { + return nil + }, + } + cmd.Flags().Var(flag, "test-flag", "test") + + var err1, err2 error + if tt.value1 != nil { + err1 = cmd.Flags().Set("test-flag", *tt.value1) + } + if tt.value2 != nil { + err2 = cmd.Flags().Set("test-flag", *tt.value2) + } + + if !tt.isValid && err1 == nil && err2 == nil { + t.Fatalf("did not fail on invalid input") + } + if !tt.isValid { + return + } + + if err1 != nil { + t.Fatalf("failed on valid input: %v", err1) + } + if err2 != nil { + t.Fatalf("failed on valid input: %v", err2) + } + value, err := cmd.Flags().GetStringSlice("test-flag") + if err != nil { + t.Fatalf("failed to get value: %v", err) + } + if !reflect.DeepEqual(tt.expectedValue, value) { + t.Fatalf("flag did not return set value (expected %s, got %s)", tt.expectedValue, value) + } + }) + } +} + +func TestUUIDFlag(t *testing.T) { + tests := []struct { + description string + value string + isValid bool + }{ + { + description: "valid", + value: uuid.NewString(), + isValid: true, + }, + { + description: "empty", + value: "", + isValid: false, + }, + { + description: "invalid", + value: "invalid-uuid", + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + flag := UUIDFlag() + cmd := &cobra.Command{ + Use: "test", + RunE: func(cmd *cobra.Command, args []string) error { + return nil + }, + } + cmd.Flags().Var(flag, "test-flag", "test") + + err := cmd.Flags().Set("test-flag", tt.value) + + if !tt.isValid && err == nil { + t.Fatalf("did not fail on invalid input") + } + if !tt.isValid { + return + } + + if err != nil { + t.Fatalf("failed on valid input: %v", err) + } + value := FlagToStringValue(cmd, "test-flag") + if value != tt.value { + t.Fatalf("flag did not return set value") + } + }) + } +} + +func TestUUIDSliceFlag(t *testing.T) { + testUUID1 := uuid.NewString() + testUUID2 := uuid.NewString() + testUUID3 := uuid.NewString() + testUUID4 := uuid.NewString() + tests := []struct { + description string + value1 *string + value2 *string + expectedValue []string + isValid bool + }{ + { + description: "valid two single values", + value1: utils.Ptr(testUUID1), + value2: utils.Ptr(testUUID2), + expectedValue: []string{testUUID1, testUUID2}, + isValid: true, + }, + { + description: "valid list value", + value1: utils.Ptr(fmt.Sprintf("%s,%s", testUUID1, testUUID2)), + expectedValue: []string{testUUID1, testUUID2}, + isValid: true, + }, + { + description: "valid list value and single value", + value1: utils.Ptr(fmt.Sprintf("%s,%s", testUUID1, testUUID2)), + value2: utils.Ptr(testUUID3), + expectedValue: []string{testUUID1, testUUID2, testUUID3}, + isValid: true, + }, + { + description: "valid two list values", + value1: utils.Ptr(fmt.Sprintf("%s,%s", testUUID1, testUUID2)), + value2: utils.Ptr(fmt.Sprintf("%s,%s", testUUID3, testUUID4)), + expectedValue: []string{testUUID1, testUUID2, testUUID3, testUUID4}, + isValid: true, + }, + { + description: "invalid value", + value1: utils.Ptr("invalid-UUID"), + value2: utils.Ptr(testUUID1), + isValid: false, + }, + { + description: "invalid value in list", + value1: utils.Ptr(fmt.Sprintf("invalid-UUID,%s", testUUID1)), + isValid: false, + }, + { + description: "invalid empty value", + value1: utils.Ptr(""), + isValid: false, + }, + { + description: "invalid empty value in list", + value1: utils.Ptr(fmt.Sprintf(",%s", testUUID1)), + isValid: false, + }, + { + description: "no values", + expectedValue: nil, + isValid: true, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + flag := UUIDSliceFlag() + cmd := &cobra.Command{ + Use: "test", + RunE: func(cmd *cobra.Command, args []string) error { + return nil + }, + } + cmd.Flags().Var(flag, "test-flag", "test") + + var err1, err2 error + if tt.value1 != nil { + err1 = cmd.Flags().Set("test-flag", *tt.value1) + } + if tt.value2 != nil { + err2 = cmd.Flags().Set("test-flag", *tt.value2) + } + + if !tt.isValid && err1 == nil && err2 == nil { + t.Fatalf("did not fail on invalid input") + } + if !tt.isValid { + return + } + + if err1 != nil { + t.Fatalf("failed on valid input: %v", err1) + } + if err2 != nil { + t.Fatalf("failed on valid input: %v", err2) + } + value := FlagToStringSliceValue(cmd, "test-flag") + if !reflect.DeepEqual(tt.expectedValue, value) { + t.Fatalf("flag did not return set value (expected %s, got %s)", tt.expectedValue, value) + } + }) + } +} + +func TestCIDRFlag(t *testing.T) { + tests := []struct { + description string + value string + isValid bool + }{ + { + description: "valid IPv4 block", + value: "198.51.100.14/24", + isValid: true, + }, + { + description: "valid IPv4 block 2", + value: "111.222.111.222/22", + isValid: true, + }, + { + description: "valid IPv4 single", + value: "198.51.100.14/32", + isValid: true, + }, + { + description: "valid IPv4 entire internet", + value: "0.0.0.0/0", + isValid: true, + }, + { + description: "invalid IPv4 block", + value: "198.51.100.14/33", + isValid: false, + }, + { + description: "invalid IPv4 no block", + value: "111.222.111.222", + isValid: false, + }, + { + description: "valid IPv6 block", + value: "2001:db8::/48", + isValid: true, + }, + { + description: "valid IPv6 single", + value: "2001:0db8:85a3:08d3::0370:7344/128", + isValid: true, + }, + { + description: "valid IPv6 entire internet", + value: "::/0", + isValid: true, + }, + { + description: "invalid IPv6 block", + value: "2001:0db8:85a3:08d3::0370:7344/129", + isValid: false, + }, + { + description: "invalid IPv6 no block", + value: "2001:0db8:85a3:08d3::0370:7344", + isValid: false, + }, + { + description: "invalid", + value: "invalid-uuid", + isValid: false, + }, + { + description: "empty", + value: "", + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + flag := CIDRFlag() + cmd := &cobra.Command{ + Use: "test", + RunE: func(cmd *cobra.Command, args []string) error { + return nil + }, + } + cmd.Flags().Var(flag, "test-flag", "test") + + err := cmd.Flags().Set("test-flag", tt.value) + + if !tt.isValid && err == nil { + t.Fatalf("did not fail on invalid input") + } + if !tt.isValid { + return + } + + if err != nil { + t.Fatalf("failed on valid input: %v", err) + } + value := FlagToStringValue(cmd, "test-flag") + if value != tt.value { + t.Fatalf("flag did not return set value") + } + }) + } +} + +func TestCIDRSliceFlag(t *testing.T) { + tests := []struct { + description string + value1 *string + value2 *string + expectedValue []string + isValid bool + }{ + { + description: "valid two single values", + value1: utils.Ptr("198.51.100.14/24"), + value2: utils.Ptr("198.51.100.14/32"), + expectedValue: []string{"198.51.100.14/24", "198.51.100.14/32"}, + isValid: true, + }, + { + description: "valid list value", + value1: utils.Ptr("198.51.100.14/24,198.51.100.14/32"), + expectedValue: []string{"198.51.100.14/24", "198.51.100.14/32"}, + isValid: true, + }, + { + description: "valid list value and single value", + value1: utils.Ptr("198.51.100.14/24,198.51.100.14/32"), + value2: utils.Ptr("111.222.111.222/22"), + expectedValue: []string{"198.51.100.14/24", "198.51.100.14/32", "111.222.111.222/22"}, + isValid: true, + }, + { + description: "valid two list values", + value1: utils.Ptr("198.51.100.14/24,198.51.100.14/32"), + value2: utils.Ptr("111.222.111.222/22,2001:db8::/48"), + expectedValue: []string{"198.51.100.14/24", "198.51.100.14/32", "111.222.111.222/22", "2001:db8::/48"}, + isValid: true, + }, + { + description: "invalid value", + value1: utils.Ptr("invalid-cidr"), + value2: utils.Ptr("198.51.100.14/24"), + isValid: false, + }, + { + description: "invalid value in list", + value1: utils.Ptr("198.51.100.14/24,invalid-cidr"), + isValid: false, + }, + { + description: "invalid empty value", + value1: utils.Ptr(""), + isValid: false, + }, + { + description: "invalid empty value in list", + value1: utils.Ptr("198.51.100.14/24,198.51.100.14/32,"), + isValid: false, + }, + { + description: "no values", + expectedValue: nil, + isValid: true, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + flag := CIDRSliceFlag() + cmd := &cobra.Command{ + Use: "test", + RunE: func(cmd *cobra.Command, args []string) error { + return nil + }, + } + cmd.Flags().Var(flag, "test-flag", "test") + + var err1, err2 error + if tt.value1 != nil { + err1 = cmd.Flags().Set("test-flag", *tt.value1) + } + if tt.value2 != nil { + err2 = cmd.Flags().Set("test-flag", *tt.value2) + } + + if !tt.isValid && err1 == nil && err2 == nil { + t.Fatalf("did not fail on invalid input") + } + if !tt.isValid { + return + } + + if err1 != nil { + t.Fatalf("failed on valid input: %v", err1) + } + if err2 != nil { + t.Fatalf("failed on valid input: %v", err2) + } + value := FlagToStringSliceValue(cmd, "test-flag") + if !reflect.DeepEqual(tt.expectedValue, value) { + t.Fatalf("flag did not return set value (expected %s, got %s)", tt.expectedValue, value) + } + }) + } +} + +func TestReadFromFileFlag(t *testing.T) { + tests := []struct { + description string + value string + fileValue string + readFileFails bool + expectedValue string + expectedFileName string // If "", file isn't exected to be called + }{ + { + description: "base", + value: "foo", + expectedValue: "foo", + }, + { + description: "empty", + value: "", + expectedValue: "", + }, + { + description: "read from file 1", + value: "@foo", + fileValue: "bar", + expectedValue: "bar", + expectedFileName: "foo", + }, + { + description: "read from file 2", + value: "@\"foo\"", + fileValue: "bar", + expectedValue: "bar", + expectedFileName: "foo", + }, + { + description: "read from file 3", + value: "@'foo'", + fileValue: "bar", + expectedValue: "bar", + expectedFileName: "foo", + }, + { + description: "read from file fails", + value: "@foo", + readFileFails: true, + expectedFileName: "foo", + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + readFileCalled := false + reader := func(filename string) ([]byte, error) { + readFileCalled = true + if filename != tt.expectedFileName { + t.Errorf("expected file name %q, got %q instead", tt.expectedFileName, filename) + } + if tt.readFileFails { + return nil, fmt.Errorf("something failed") + } + return []byte(tt.fileValue), nil + } + + flag := &readFromFileFlag{ + reader: reader, + } + cmd := &cobra.Command{ + Use: "test", + RunE: func(cmd *cobra.Command, args []string) error { + return nil + }, + } + cmd.Flags().Var(flag, "test-flag", "test") + + err := cmd.Flags().Set("test-flag", tt.value) + + if !readFileCalled && (tt.expectedFileName != "") { + t.Errorf("read file should've been called") + } + if readFileCalled && (tt.expectedFileName == "") { + t.Errorf("read file shouldn't have been called") + } + if tt.readFileFails && err == nil { + t.Fatalf("did not fail on invalid input") + } + if tt.readFileFails { + return + } + + if err != nil { + t.Fatalf("failed on valid input: %v", err) + } + value := FlagToStringValue(cmd, "test-flag") + if value != tt.expectedValue { + t.Fatalf("flag returned %q, expected %q", value, tt.expectedValue) + } + }) + } +} diff --git a/internal/pkg/flags/read_from_file.go b/internal/pkg/flags/read_from_file.go new file mode 100644 index 00000000..2b52458a --- /dev/null +++ b/internal/pkg/flags/read_from_file.go @@ -0,0 +1,49 @@ +package flags + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/pflag" +) + +type readFromFileFlag struct { + // Used to read file. + // Set to os.ReadFile, except during tests + reader func(filename string) ([]byte, error) + value string +} + +// Ensure the implementation satisfies the expected interface +var _ pflag.Value = &readFromFileFlag{} + +// ReadFromFileFlag returns a string flag. +// If it starts with "@", it is assumed to be a file path and content is read from file instead +func ReadFromFileFlag() *readFromFileFlag { + return &readFromFileFlag{ + reader: os.ReadFile, + } +} + +func (f *readFromFileFlag) String() string { + return f.value +} + +func (f *readFromFileFlag) Set(value string) error { + if !strings.HasPrefix(value, "@") { + f.value = value + } else { + valuePath := strings.Trim(value[1:], `"'`) + valueBytes, err := f.reader(valuePath) + if err != nil { + return fmt.Errorf("read data from file: %w", err) + } + f.value = string(valueBytes) + } + return nil +} + +func (f *readFromFileFlag) Type() string { + return "string" +} diff --git a/internal/pkg/flags/uuid.go b/internal/pkg/flags/uuid.go new file mode 100644 index 00000000..c1df9481 --- /dev/null +++ b/internal/pkg/flags/uuid.go @@ -0,0 +1,40 @@ +package flags + +import ( + "fmt" + "stackit/internal/pkg/utils" + + "github.com/spf13/pflag" +) + +type uuidFlag struct { + value string +} + +// Ensure the implementation satisfies the expected interface +var _ pflag.Value = &uuidFlag{} + +// UUIDFlag returns a flag which must be a valid UUID. +func UUIDFlag() *uuidFlag { + return &uuidFlag{} +} + +func (f *uuidFlag) String() string { + return f.value +} + +func (f *uuidFlag) Set(value string) error { + if value == "" { + return fmt.Errorf("value cannot be empty") + } + err := utils.ValidateUUID(value) + if err != nil { + return err + } + f.value = value + return nil +} + +func (f *uuidFlag) Type() string { + return "string" +} diff --git a/internal/pkg/flags/uuidslice.go b/internal/pkg/flags/uuidslice.go new file mode 100644 index 00000000..5a94c879 --- /dev/null +++ b/internal/pkg/flags/uuidslice.go @@ -0,0 +1,49 @@ +package flags + +import ( + "fmt" + "stackit/internal/pkg/utils" + "strings" + + "github.com/spf13/pflag" +) + +type uuidSliceFlag struct { + value []string +} + +// Ensure the implementation satisfies the expected interface +var _ pflag.Value = &uuidFlag{} + +// UUIDSliceFlag returns a flag which must be a valid slice. +func UUIDSliceFlag() *uuidSliceFlag { + return &uuidSliceFlag{} +} + +func (f *uuidSliceFlag) String() string { + return "[" + strings.Join(f.value, ",") + "]" +} + +func (f *uuidSliceFlag) Set(value string) error { + if value == "" { + return fmt.Errorf("value cannot be empty") + } + + uuids := strings.Split(value, ",") + + for i, uuid := range uuids { + uuids[i] = strings.TrimSpace(uuid) + + err := utils.ValidateUUID(uuids[i]) + if err != nil { + return err + } + } + + f.value = append(f.value, uuids...) + return nil +} + +func (f *uuidSliceFlag) Type() string { + return "stringSlice" +} diff --git a/internal/pkg/globalflags/global_flags.go b/internal/pkg/globalflags/global_flags.go new file mode 100644 index 00000000..6a202293 --- /dev/null +++ b/internal/pkg/globalflags/global_flags.go @@ -0,0 +1,63 @@ +package globalflags + +import ( + "fmt" + + "stackit/internal/pkg/config" + "stackit/internal/pkg/flags" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/spf13/viper" +) + +const ( + AsyncFlag = "async" + AssumeYesFlag = "assume-yes" + OutputFormatFlag = "output-format" + ProjectIdFlag = "project-id" + + JSONOutputFormat = "json" + PrettyOutputFormat = "pretty" +) + +var outputFormatFlagOptions = []string{JSONOutputFormat, PrettyOutputFormat} + +type GlobalFlagModel struct { + Async bool + AssumeYes bool + OutputFormat string + ProjectId string +} + +func Configure(flagSet *pflag.FlagSet) error { + flagSet.VarP(flags.UUIDFlag(), ProjectIdFlag, "p", "Project ID") + err := viper.BindPFlag(config.ProjectIdKey, flagSet.Lookup(ProjectIdFlag)) + if err != nil { + return fmt.Errorf("bind --%s flag to config: %w", ProjectIdFlag, err) + } + + flagSet.VarP(flags.EnumFlag(true, "", outputFormatFlagOptions...), OutputFormatFlag, "o", fmt.Sprintf("Output format, one of %q", outputFormatFlagOptions)) + err = viper.BindPFlag(config.OutputFormatKey, flagSet.Lookup(OutputFormatFlag)) + if err != nil { + return fmt.Errorf("bind --%s flag to config: %w", OutputFormatFlag, err) + } + + flagSet.Bool(AsyncFlag, false, "If set, runs the command asynchronously") + err = viper.BindPFlag(config.AsyncKey, flagSet.Lookup(AsyncFlag)) + if err != nil { + return fmt.Errorf("bind --%s flag to config: %w", AsyncFlag, err) + } + + flagSet.BoolP(AssumeYesFlag, "y", false, "If set, skips all confirmation prompts") + return nil +} + +func Parse(cmd *cobra.Command) *GlobalFlagModel { + return &GlobalFlagModel{ + Async: viper.GetBool(config.AsyncKey), + AssumeYes: flags.FlagToBoolValue(cmd, AssumeYesFlag), + OutputFormat: viper.GetString(config.OutputFormatKey), + ProjectId: viper.GetString(config.ProjectIdKey), + } +} diff --git a/internal/pkg/pager/pager.go b/internal/pkg/pager/pager.go new file mode 100644 index 00000000..401f9508 --- /dev/null +++ b/internal/pkg/pager/pager.go @@ -0,0 +1,22 @@ +package pager + +import ( + "fmt" + "os/exec" + "strings" + + "github.com/spf13/cobra" +) + +// Shows the content in the command's stdout using the "less" command +func Display(cmd *cobra.Command, content string) error { + lessCmd := exec.Command("less", "-F", "-S", "-w") + lessCmd.Stdin = strings.NewReader(content) + lessCmd.Stdout = cmd.OutOrStdout() + + err := lessCmd.Run() + if err != nil { + return fmt.Errorf("run less command: %w", err) + } + return nil +} diff --git a/internal/pkg/projectname/project_name.go b/internal/pkg/projectname/project_name.go new file mode 100644 index 00000000..f441e546 --- /dev/null +++ b/internal/pkg/projectname/project_name.go @@ -0,0 +1,77 @@ +package projectname + +import ( + "context" + "fmt" + "stackit/internal/pkg/config" + "stackit/internal/pkg/flags" + "stackit/internal/pkg/globalflags" + "stackit/internal/pkg/services/resourcemanager/client" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// Returns the project name associated to the project ID set in config +// +// Uses the one stored in config if it's valid, otherwise gets it from the API +func GetProjectName(ctx context.Context, cmd *cobra.Command) (string, error) { + // If we can use the project name from config, return it + if useProjectNameFromConfig(cmd) { + return viper.GetString(config.ProjectNameKey), nil + } + + projectId := viper.GetString(config.ProjectIdKey) + if projectId == "" { + return "", fmt.Errorf("found empty project ID and name") + } + + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return "", fmt.Errorf("configure resource manager client: %w", err) + } + req := apiClient.GetProject(ctx, projectId) + resp, err := req.Execute() + if err != nil { + return "", fmt.Errorf("read project details: %w", err) + } + projectName := *resp.Name + + // If project ID is set in config, we store the project name in config + // (So next time we can just pull it from there) + if !isProjectIdSetInFlags(cmd) { + viper.Set(config.ProjectNameKey, projectName) + err = viper.WriteConfig() + if err != nil { + return "", fmt.Errorf("write new config to file: %w", err) + } + } + + return projectName, nil +} + +// Returns True if project name from config should be used, False otherwise +func useProjectNameFromConfig(cmd *cobra.Command) bool { + // We use the project name from the config file, if: + // - Project id is not set to a different value than the one in the config file + // - Project name in the config file is not empty + projectIdSet := isProjectIdSetInFlags(cmd) + projectName := viper.GetString(config.ProjectNameKey) + projectNameSet := false + if projectName != "" { + projectNameSet = true + } + return !projectIdSet && projectNameSet +} + +func isProjectIdSetInFlags(cmd *cobra.Command) bool { + // FlagToStringPointer pulls the projectId from passed flags + // viper.GetString uses the flags, and fallsback to config file + // To check if projectId was passed, we use the first rather than the second + projectIdFromFlag := flags.FlagToStringPointer(cmd, globalflags.ProjectIdFlag) + projectIdSetInFlag := false + if projectIdFromFlag != nil { + projectIdSetInFlag = true + } + return projectIdSetInFlag +} diff --git a/internal/pkg/services/dns/client/client.go b/internal/pkg/services/dns/client/client.go new file mode 100644 index 00000000..78035fa8 --- /dev/null +++ b/internal/pkg/services/dns/client/client.go @@ -0,0 +1,37 @@ +package client + +import ( + "stackit/internal/pkg/auth" + "stackit/internal/pkg/config" + "stackit/internal/pkg/errors" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/services/dns" +) + +func ConfigureClient(cmd *cobra.Command) (*dns.APIClient, error) { + var err error + var apiClient *dns.APIClient + var cfgOptions []sdkConfig.ConfigurationOption + + authCfgOption, err := auth.AuthenticationConfig(cmd, auth.AuthorizeUser) + if err != nil { + return nil, &errors.AuthError{} + } + cfgOptions = append(cfgOptions, authCfgOption) + + customEndpoint := viper.GetString(config.DNSCustomEndpointKey) + + if customEndpoint != "" { + cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) + } + + apiClient, err = dns.NewAPIClient(cfgOptions...) + if err != nil { + return nil, &errors.AuthError{} + } + + return apiClient, nil +} diff --git a/internal/pkg/services/dns/utils/utils.go b/internal/pkg/services/dns/utils/utils.go new file mode 100644 index 00000000..da57eb96 --- /dev/null +++ b/internal/pkg/services/dns/utils/utils.go @@ -0,0 +1,29 @@ +package utils + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-sdk-go/services/dns" +) + +type DNSClient interface { + GetZoneExecute(ctx context.Context, projectId, zoneId string) (*dns.ZoneResponse, error) + GetRecordSetExecute(ctx context.Context, projectId, zoneId, recordSetId string) (*dns.RecordSetResponse, error) +} + +func GetZoneName(ctx context.Context, apiClient DNSClient, projectId, zoneId string) (string, error) { + resp, err := apiClient.GetZoneExecute(ctx, projectId, zoneId) + if err != nil { + return "", fmt.Errorf("get DNS zone: %w", err) + } + return *resp.Zone.Name, nil +} + +func GetRecordSetName(ctx context.Context, apiClient DNSClient, projectId, zoneId, recordSetId string) (string, error) { + resp, err := apiClient.GetRecordSetExecute(ctx, projectId, zoneId, recordSetId) + if err != nil { + return "", fmt.Errorf("get DNS recordset: %w", err) + } + return *resp.Rrset.Name, nil +} diff --git a/internal/pkg/services/dns/utils/utils_test.go b/internal/pkg/services/dns/utils/utils_test.go new file mode 100644 index 00000000..eb3051ef --- /dev/null +++ b/internal/pkg/services/dns/utils/utils_test.go @@ -0,0 +1,144 @@ +package utils + +import ( + "context" + "fmt" + "testing" + + "stackit/internal/pkg/utils" + + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/dns" +) + +var ( + testProjectId = uuid.NewString() + testZoneId = uuid.NewString() + testRecordSetId = uuid.NewString() +) + +const ( + testZoneName = "zone" + testRecordSetName = "record-set" +) + +type dnsClientMocked struct { + getZoneFails bool + getZoneResp *dns.ZoneResponse + getRecordSetFails bool + getRecordSetResp *dns.RecordSetResponse +} + +func (m *dnsClientMocked) GetZoneExecute(_ context.Context, _, _ string) (*dns.ZoneResponse, error) { + if m.getZoneFails { + return nil, fmt.Errorf("could not get zone") + } + return m.getZoneResp, nil +} + +func (m *dnsClientMocked) GetRecordSetExecute(_ context.Context, _, _, _ string) (*dns.RecordSetResponse, error) { + if m.getRecordSetFails { + return nil, fmt.Errorf("could not get record set") + } + return m.getRecordSetResp, nil +} + +func TestGetZoneName(t *testing.T) { + tests := []struct { + description string + getZoneFails bool + getZoneResp *dns.ZoneResponse + isValid bool + expectedOutput string + }{ + { + description: "base", + getZoneResp: &dns.ZoneResponse{ + Zone: &dns.Zone{ + Name: utils.Ptr(testZoneName), + }, + }, + isValid: true, + expectedOutput: testZoneName, + }, + { + description: "get zone fails", + getZoneFails: true, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := &dnsClientMocked{ + getZoneFails: tt.getZoneFails, + getZoneResp: tt.getZoneResp, + } + + output, err := GetZoneName(context.Background(), client, testProjectId, testZoneId) + + if tt.isValid && err != nil { + t.Errorf("failed on valid input") + } + if !tt.isValid && err == nil { + t.Errorf("did not fail on invalid input") + } + if !tt.isValid { + return + } + if output != tt.expectedOutput { + t.Errorf("expected output to be %s, got %s", tt.expectedOutput, output) + } + }) + } +} + +func TestGetRecordSetName(t *testing.T) { + tests := []struct { + description string + getRecordSetFails bool + getRecordSetResp *dns.RecordSetResponse + isValid bool + expectedOutput string + }{ + { + description: "base", + getRecordSetResp: &dns.RecordSetResponse{ + Rrset: &dns.RecordSet{ + Name: utils.Ptr(testRecordSetName), + }, + }, + isValid: true, + expectedOutput: testRecordSetName, + }, + { + description: "get record set fails", + getRecordSetFails: true, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := &dnsClientMocked{ + getRecordSetFails: tt.getRecordSetFails, + getRecordSetResp: tt.getRecordSetResp, + } + + output, err := GetRecordSetName(context.Background(), client, testProjectId, testZoneId, testRecordSetId) + + if tt.isValid && err != nil { + t.Errorf("failed on valid input") + } + if !tt.isValid && err == nil { + t.Errorf("did not fail on invalid input") + } + if !tt.isValid { + return + } + if output != tt.expectedOutput { + t.Errorf("expected output to be %s, got %s", tt.expectedOutput, output) + } + }) + } +} diff --git a/internal/pkg/services/membership/client/client.go b/internal/pkg/services/membership/client/client.go new file mode 100644 index 00000000..2187cea3 --- /dev/null +++ b/internal/pkg/services/membership/client/client.go @@ -0,0 +1,37 @@ +package client + +import ( + "stackit/internal/pkg/auth" + "stackit/internal/pkg/config" + "stackit/internal/pkg/errors" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/services/membership" +) + +func ConfigureClient(cmd *cobra.Command) (*membership.APIClient, error) { + var err error + var apiClient *membership.APIClient + var cfgOptions []sdkConfig.ConfigurationOption + + authCfgOption, err := auth.AuthenticationConfig(cmd, auth.AuthorizeUser) + if err != nil { + return nil, &errors.AuthError{} + } + cfgOptions = append(cfgOptions, authCfgOption) + + customEndpoint := viper.GetString(config.MembershipCustomEndpointKey) + + if customEndpoint != "" { + cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) + } + + apiClient, err = membership.NewAPIClient(cfgOptions...) + if err != nil { + return nil, &errors.AuthError{} + } + + return apiClient, nil +} diff --git a/internal/pkg/services/mongodbflex/client/client.go b/internal/pkg/services/mongodbflex/client/client.go new file mode 100644 index 00000000..71e08129 --- /dev/null +++ b/internal/pkg/services/mongodbflex/client/client.go @@ -0,0 +1,37 @@ +package client + +import ( + "stackit/internal/pkg/auth" + "stackit/internal/pkg/config" + "stackit/internal/pkg/errors" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" +) + +func ConfigureClient(cmd *cobra.Command) (*mongodbflex.APIClient, error) { + var err error + var apiClient *mongodbflex.APIClient + var cfgOptions []sdkConfig.ConfigurationOption + + authCfgOption, err := auth.AuthenticationConfig(cmd, auth.AuthorizeUser) + if err != nil { + return nil, &errors.AuthError{} + } + cfgOptions = append(cfgOptions, authCfgOption, sdkConfig.WithRegion("eu01")) + + customEndpoint := viper.GetString(config.MongoDBFlexCustomEndpointKey) + + if customEndpoint != "" { + cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) + } + + apiClient, err = mongodbflex.NewAPIClient(cfgOptions...) + if err != nil { + return nil, &errors.AuthError{} + } + + return apiClient, nil +} diff --git a/internal/pkg/services/mongodbflex/utils/utils.go b/internal/pkg/services/mongodbflex/utils/utils.go new file mode 100644 index 00000000..a674cd42 --- /dev/null +++ b/internal/pkg/services/mongodbflex/utils/utils.go @@ -0,0 +1,84 @@ +package utils + +import ( + "context" + "fmt" + "stackit/internal/pkg/errors" + "strings" + + "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" +) + +func ValidateFlavorId(service, flavorId string, flavors *[]mongodbflex.HandlersInfraFlavor) error { + for _, f := range *flavors { + if f.Id != nil && strings.EqualFold(*f.Id, flavorId) { + return nil + } + } + + return &errors.DatabaseInvalidFlavorError{ + Service: service, + Details: fmt.Sprintf("You provided flavor ID '%s', which is invalid.", flavorId), + } +} + +func ValidateStorage(service string, storageClass *string, storageSize *int64, storages *mongodbflex.ListStoragesResponse, flavorId string) error { + if storageSize != nil { + if *storageSize < *storages.StorageRange.Min || *storageSize > *storages.StorageRange.Max { + return fmt.Errorf("%s", fmt.Sprintf("You provided storage size '%d', which is invalid. The valid range is %d-%d.", *storageSize, *storages.StorageRange.Min, *storages.StorageRange.Max)) + } + } + + if storageClass == nil { + return nil + } + + for _, sc := range *storages.StorageClasses { + if strings.EqualFold(*storageClass, sc) { + return nil + } + } + return &errors.DatabaseInvalidStorageError{ + Service: service, + Details: fmt.Sprintf("You provided storage class '%s', which is invalid.", *storageClass), + FlavorId: flavorId, + } +} + +func LoadFlavorId(service string, cpu, ram int64, flavors *[]mongodbflex.HandlersInfraFlavor) (*string, error) { + availableFlavors := "" + for _, f := range *flavors { + if f.Id == nil || f.Cpu == nil || f.Memory == nil { + continue + } + if *f.Cpu == cpu && *f.Memory == ram { + return f.Id, nil + } + availableFlavors = fmt.Sprintf("%s\n- %d CPU, %d GB RAM", availableFlavors, *f.Cpu, *f.Cpu) + } + return nil, &errors.DatabaseInvalidFlavorError{ + Service: service, + Details: "You provided an invalid combination for CPU and RAM.", + } +} + +type MongoDBFlexClient interface { + GetInstanceExecute(ctx context.Context, projectId, instanceId string) (*mongodbflex.GetInstanceResponse, error) + GetUserExecute(ctx context.Context, projectId, instanceId, userId string) (*mongodbflex.GetUserResponse, error) +} + +func GetInstanceName(ctx context.Context, apiClient MongoDBFlexClient, projectId, instanceId string) (string, error) { + resp, err := apiClient.GetInstanceExecute(ctx, projectId, instanceId) + if err != nil { + return "", fmt.Errorf("get MongoDBFlex instance: %w", err) + } + return *resp.Item.Name, nil +} + +func GetUserName(ctx context.Context, apiClient MongoDBFlexClient, projectId, instanceId, userId string) (string, error) { + resp, err := apiClient.GetUserExecute(ctx, projectId, instanceId, userId) + if err != nil { + return "", fmt.Errorf("get MongoDBFlex user: %w", err) + } + return *resp.Item.Username, nil +} diff --git a/internal/pkg/services/mongodbflex/utils/utils_test.go b/internal/pkg/services/mongodbflex/utils/utils_test.go new file mode 100644 index 00000000..abb6febc --- /dev/null +++ b/internal/pkg/services/mongodbflex/utils/utils_test.go @@ -0,0 +1,144 @@ +package utils + +import ( + "context" + "fmt" + "testing" + + "stackit/internal/pkg/utils" + + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/mongodbflex" +) + +var ( + testProjectId = uuid.NewString() + testInstanceId = uuid.NewString() + testUserId = uuid.NewString() +) + +const ( + testInstanceName = "instance" + testUserName = "user" +) + +type mongoDBFlexClientMocked struct { + getInstanceFails bool + getInstanceResp *mongodbflex.GetInstanceResponse + getUserFails bool + getUserResp *mongodbflex.GetUserResponse +} + +func (m *mongoDBFlexClientMocked) GetInstanceExecute(_ context.Context, _, _ string) (*mongodbflex.GetInstanceResponse, error) { + if m.getInstanceFails { + return nil, fmt.Errorf("could not get instance") + } + return m.getInstanceResp, nil +} + +func (m *mongoDBFlexClientMocked) GetUserExecute(_ context.Context, _, _, _ string) (*mongodbflex.GetUserResponse, error) { + if m.getUserFails { + return nil, fmt.Errorf("could not get user") + } + return m.getUserResp, nil +} + +func TestGetInstanceName(t *testing.T) { + tests := []struct { + description string + getInstanceFails bool + getInstanceResp *mongodbflex.GetInstanceResponse + isValid bool + expectedOutput string + }{ + { + description: "base", + getInstanceResp: &mongodbflex.GetInstanceResponse{ + Item: &mongodbflex.Instance{ + Name: utils.Ptr(testInstanceName), + }, + }, + isValid: true, + expectedOutput: testInstanceName, + }, + { + description: "get instance fails", + getInstanceFails: true, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := &mongoDBFlexClientMocked{ + getInstanceFails: tt.getInstanceFails, + getInstanceResp: tt.getInstanceResp, + } + + output, err := GetInstanceName(context.Background(), client, testProjectId, testInstanceId) + + if tt.isValid && err != nil { + t.Errorf("failed on valid input") + } + if !tt.isValid && err == nil { + t.Errorf("did not fail on invalid input") + } + if !tt.isValid { + return + } + if output != tt.expectedOutput { + t.Errorf("expected output to be %s, got %s", tt.expectedOutput, output) + } + }) + } +} + +func TestGetUserName(t *testing.T) { + tests := []struct { + description string + getUserFails bool + getUserResp *mongodbflex.GetUserResponse + isValid bool + expectedOutput string + }{ + { + description: "base", + getUserResp: &mongodbflex.GetUserResponse{ + Item: &mongodbflex.InstanceResponseUser{ + Username: utils.Ptr(testUserName), + }, + }, + isValid: true, + expectedOutput: testUserName, + }, + { + description: "get user fails", + getUserFails: true, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := &mongoDBFlexClientMocked{ + getUserFails: tt.getUserFails, + getUserResp: tt.getUserResp, + } + + output, err := GetUserName(context.Background(), client, testProjectId, testInstanceId, testUserId) + + if tt.isValid && err != nil { + t.Errorf("failed on valid input") + } + if !tt.isValid && err == nil { + t.Errorf("did not fail on invalid input") + } + if !tt.isValid { + return + } + if output != tt.expectedOutput { + t.Errorf("expected output to be %s, got %s", tt.expectedOutput, output) + } + }) + } +} diff --git a/internal/pkg/services/resourcemanager/client/client.go b/internal/pkg/services/resourcemanager/client/client.go new file mode 100644 index 00000000..e07152ca --- /dev/null +++ b/internal/pkg/services/resourcemanager/client/client.go @@ -0,0 +1,37 @@ +package client + +import ( + "stackit/internal/pkg/auth" + "stackit/internal/pkg/config" + "stackit/internal/pkg/errors" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/services/resourcemanager" +) + +func ConfigureClient(cmd *cobra.Command) (*resourcemanager.APIClient, error) { + var err error + var apiClient *resourcemanager.APIClient + var cfgOptions []sdkConfig.ConfigurationOption + + authCfgOption, err := auth.AuthenticationConfig(cmd, auth.AuthorizeUser) + if err != nil { + return nil, &errors.AuthError{} + } + cfgOptions = append(cfgOptions, authCfgOption) + + customEndpoint := viper.GetString(config.ResourceManagerEndpointKey) + + if customEndpoint != "" { + cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) + } + + apiClient, err = resourcemanager.NewAPIClient(cfgOptions...) + if err != nil { + return nil, &errors.AuthError{} + } + + return apiClient, nil +} diff --git a/internal/pkg/services/service-account/client/client.go b/internal/pkg/services/service-account/client/client.go new file mode 100644 index 00000000..02769cee --- /dev/null +++ b/internal/pkg/services/service-account/client/client.go @@ -0,0 +1,37 @@ +package client + +import ( + "stackit/internal/pkg/auth" + "stackit/internal/pkg/config" + "stackit/internal/pkg/errors" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/services/serviceaccount" +) + +func ConfigureClient(cmd *cobra.Command) (*serviceaccount.APIClient, error) { + var err error + var apiClient *serviceaccount.APIClient + var cfgOptions []sdkConfig.ConfigurationOption + + authCfgOption, err := auth.AuthenticationConfig(cmd, auth.AuthorizeUser) + if err != nil { + return nil, &errors.AuthError{} + } + cfgOptions = append(cfgOptions, authCfgOption) + + customEndpoint := viper.GetString(config.ServiceAccountCustomEndpointKey) + + if customEndpoint != "" { + cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) + } + + apiClient, err = serviceaccount.NewAPIClient(cfgOptions...) + if err != nil { + return nil, &errors.AuthError{} + } + + return apiClient, nil +} diff --git a/internal/pkg/services/ske/client/client.go b/internal/pkg/services/ske/client/client.go new file mode 100644 index 00000000..f86307e7 --- /dev/null +++ b/internal/pkg/services/ske/client/client.go @@ -0,0 +1,38 @@ +package client + +import ( + "stackit/internal/pkg/auth" + "stackit/internal/pkg/config" + "stackit/internal/pkg/errors" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + sdkConfig "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/services/ske" +) + +func ConfigureClient(cmd *cobra.Command) (*ske.APIClient, error) { + var err error + var apiClient *ske.APIClient + var cfgOptions []sdkConfig.ConfigurationOption + + authCfgOption, err := auth.AuthenticationConfig(cmd, auth.AuthorizeUser) + if err != nil { + return nil, &errors.AuthError{} + } + cfgOptions = append(cfgOptions, authCfgOption) + + customEndpoint := viper.GetString(config.SKECustomEndpointKey) + if customEndpoint != "" { + cfgOptions = append(cfgOptions, sdkConfig.WithEndpoint(customEndpoint)) + } else { + cfgOptions = append(cfgOptions, authCfgOption, sdkConfig.WithRegion("eu01")) + } + + apiClient, err = ske.NewAPIClient(cfgOptions...) + if err != nil { + return nil, &errors.AuthError{} + } + + return apiClient, nil +} diff --git a/internal/pkg/services/ske/utils/utils.go b/internal/pkg/services/ske/utils/utils.go new file mode 100644 index 00000000..05915c8e --- /dev/null +++ b/internal/pkg/services/ske/utils/utils.go @@ -0,0 +1,193 @@ +package utils + +import ( + "context" + "fmt" + + "stackit/internal/pkg/utils" + + "github.com/stackitcloud/stackit-sdk-go/services/ske" + "golang.org/x/mod/semver" +) + +const ( + defaultNodepoolAvailabilityZone = "eu01-3" + defaultNodepoolCRI = "containerd" + defaultNodepoolMachineType = "b1.2" + defaultNodepoolMachineImageName = "flatcar" + defaultNodepoolMaxSurge = 1 + defaultNodepoolMaximum = 2 + defaultNodepoolMinimum = 1 + defaultNodepoolName = "pool-default" + defaultNodepoolVolumeType = "storage_premium_perf2" + defaultNodepoolVolumeSize = 50 + + supportedState = "supported" +) + +type SKEClient interface { + GetServiceStatusExecute(ctx context.Context, projectId string) (*ske.ProjectResponse, error) + ListClustersExecute(ctx context.Context, projectId string) (*ske.ListClustersResponse, error) + ListProviderOptionsExecute(ctx context.Context) (*ske.ProviderOptions, error) +} + +func ProjectEnabled(ctx context.Context, apiClient SKEClient, projectId string) (bool, error) { + project, err := apiClient.GetServiceStatusExecute(ctx, projectId) + if err != nil { + return false, fmt.Errorf("get SKE status: %w", err) + } + return *project.State == ske.PROJECTSTATE_CREATED, nil +} + +func ClusterExists(ctx context.Context, apiClient SKEClient, projectId, clusterName string) (bool, error) { + clusters, err := apiClient.ListClustersExecute(ctx, projectId) + if err != nil { + return false, fmt.Errorf("list SKE clusters: %w", err) + } + for _, cl := range *clusters.Items { + if cl.Name != nil && *cl.Name == clusterName { + return true, nil + } + } + return false, nil +} + +func GetDefaultPayload(ctx context.Context, apiClient SKEClient) (*ske.CreateOrUpdateClusterPayload, error) { + resp, err := apiClient.ListProviderOptionsExecute(ctx) + if err != nil { + return nil, fmt.Errorf("get SKE provider options: %w", err) + } + + payloadKubernetes, err := getDefaultPayloadKubernetes(resp) + if err != nil { + return nil, err + } + payloadNodepool, err := getDefaultPayloadNodepool(resp) + if err != nil { + return nil, err + } + + payload := &ske.CreateOrUpdateClusterPayload{ + Extensions: &ske.Extension{ + Acl: &ske.ACL{ + AllowedCidrs: &[]string{}, + Enabled: utils.Ptr(false), + }, + }, + Kubernetes: payloadKubernetes, + Nodepools: &[]ske.Nodepool{ + *payloadNodepool, + }, + } + return payload, nil +} + +func getDefaultPayloadKubernetes(resp *ske.ProviderOptions) (*ske.Kubernetes, error) { + output := &ske.Kubernetes{} + + if resp.KubernetesVersions == nil { + return nil, fmt.Errorf("no supported Kubernetes version found") + } + foundKubernetesVersion := false + versions := *resp.KubernetesVersions + for i := range versions { + version := versions[i] + if *version.State != supportedState { + continue + } + if output.Version != nil { + oldSemVer := fmt.Sprintf("v%s", *output.Version) + newSemVer := fmt.Sprintf("v%s", *version.Version) + if semver.Compare(newSemVer, oldSemVer) != 1 { + continue + } + } + + foundKubernetesVersion = true + output.Version = version.Version + } + if !foundKubernetesVersion { + return nil, fmt.Errorf("no supported Kubernetes version found") + } + return output, nil +} + +func getDefaultPayloadNodepool(resp *ske.ProviderOptions) (*ske.Nodepool, error) { + output := &ske.Nodepool{ + AvailabilityZones: &[]string{ + defaultNodepoolAvailabilityZone, + }, + Cri: &ske.CRI{ + Name: utils.Ptr(defaultNodepoolCRI), + }, + Machine: &ske.Machine{ + Type: utils.Ptr(defaultNodepoolMachineType), + Image: &ske.Image{ + Name: utils.Ptr(defaultNodepoolMachineImageName), + }, + }, + MaxSurge: utils.Ptr(int64(defaultNodepoolMaxSurge)), + Maximum: utils.Ptr(int64(defaultNodepoolMaximum)), + Minimum: utils.Ptr(int64(defaultNodepoolMinimum)), + Name: utils.Ptr(defaultNodepoolName), + Volume: &ske.Volume{ + Type: utils.Ptr(defaultNodepoolVolumeType), + Size: utils.Ptr(int64(defaultNodepoolVolumeSize)), + }, + } + + // Fill in Cri and Machine.Image + if resp.MachineImages == nil { + return nil, fmt.Errorf("no supported image versions found") + } + foundImageVersion := false + images := *resp.MachineImages + for i := range images { + image := images[i] + if *image.Name != defaultNodepoolMachineImageName { + continue + } + if image.Versions == nil { + continue + } + versions := *image.Versions + for j := range versions { + version := versions[j] + if *version.State != supportedState { + continue + } + + // Check if default CRI is supported + if version.Cri == nil || len(*version.Cri) == 0 { + continue + } + criSupported := false + for k := range *version.Cri { + cri := (*version.Cri)[k] + if *cri.Name == defaultNodepoolCRI { + criSupported = true + break + } + } + if !criSupported { + continue + } + + if output.Machine.Image.Version != nil { + oldSemVer := fmt.Sprintf("v%s", *output.Machine.Image.Version) + newSemVer := fmt.Sprintf("v%s", *version.Version) + if semver.Compare(newSemVer, oldSemVer) != 1 { + continue + } + } + + foundImageVersion = true + output.Machine.Image.Version = version.Version + } + } + if !foundImageVersion { + return nil, fmt.Errorf("no supported images found") + } + + return output, nil +} diff --git a/internal/pkg/services/ske/utils/utils_test.go b/internal/pkg/services/ske/utils/utils_test.go new file mode 100644 index 00000000..d86f613b --- /dev/null +++ b/internal/pkg/services/ske/utils/utils_test.go @@ -0,0 +1,443 @@ +package utils + +import ( + "context" + "fmt" + "testing" + + "stackit/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/ske" +) + +var ( + testProjectId = uuid.NewString() +) + +const ( + testClusterName = "test-cluster" +) + +type skeClientMocked struct { + getServiceStatusFails bool + getServiceStatusResp *ske.ProjectResponse + listClustersFails bool + listClustersResp *ske.ListClustersResponse + listProviderOptionsFails bool + listProviderOptionsResp *ske.ProviderOptions +} + +func (m *skeClientMocked) GetServiceStatusExecute(_ context.Context, _ string) (*ske.ProjectResponse, error) { + if m.getServiceStatusFails { + return nil, fmt.Errorf("could not get service status") + } + return m.getServiceStatusResp, nil +} + +func (m *skeClientMocked) ListClustersExecute(_ context.Context, _ string) (*ske.ListClustersResponse, error) { + if m.listClustersFails { + return nil, fmt.Errorf("could not list clusters") + } + return m.listClustersResp, nil +} + +func (m *skeClientMocked) ListProviderOptionsExecute(_ context.Context) (*ske.ProviderOptions, error) { + if m.listProviderOptionsFails { + return nil, fmt.Errorf("could not list provider options") + } + return m.listProviderOptionsResp, nil +} + +func TestProjectEnabled(t *testing.T) { + tests := []struct { + description string + getProjectFails bool + getProjectResp *ske.ProjectResponse + isValid bool + expectedOutput bool + }{ + { + description: "project enabled", + getProjectResp: &ske.ProjectResponse{State: ske.PROJECTSTATE_CREATED.Ptr()}, + isValid: true, + expectedOutput: true, + }, + { + description: "project disabled 1", + getProjectResp: &ske.ProjectResponse{State: ske.PROJECTSTATE_CREATING.Ptr()}, + isValid: true, + expectedOutput: false, + }, + { + description: "project disabled 2", + getProjectResp: &ske.ProjectResponse{State: ske.PROJECTSTATE_DELETING.Ptr()}, + isValid: true, + expectedOutput: false, + }, + { + description: "get clusters fails", + getProjectFails: true, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := &skeClientMocked{ + getServiceStatusFails: tt.getProjectFails, + getServiceStatusResp: tt.getProjectResp, + } + + output, err := ProjectEnabled(context.Background(), client, testProjectId) + + if tt.isValid && err != nil { + t.Errorf("failed on valid input") + } + if !tt.isValid && err == nil { + t.Errorf("did not fail on invalid input") + } + if !tt.isValid { + return + } + if output != tt.expectedOutput { + t.Errorf("expected output to be %t, got %t", tt.expectedOutput, output) + } + }) + } +} + +func TestClusterExists(t *testing.T) { + tests := []struct { + description string + getClustersFails bool + getClustersResp *ske.ListClustersResponse + isValid bool + expectedExists bool + }{ + { + description: "cluster exists", + getClustersResp: &ske.ListClustersResponse{Items: &[]ske.Cluster{{Name: utils.Ptr(testClusterName)}}}, + isValid: true, + expectedExists: true, + }, + { + description: "cluster exists 2", + getClustersResp: &ske.ListClustersResponse{Items: &[]ske.Cluster{{Name: utils.Ptr("some-cluster")}, {Name: utils.Ptr("some-other-cluster")}, {Name: utils.Ptr(testClusterName)}}}, + isValid: true, + expectedExists: true, + }, + { + description: "cluster does not exist", + getClustersResp: &ske.ListClustersResponse{Items: &[]ske.Cluster{{Name: utils.Ptr("some-cluster")}, {Name: utils.Ptr("some-other-cluster")}}}, + isValid: true, + expectedExists: false, + }, + { + description: "get clusters fails", + getClustersFails: true, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := &skeClientMocked{ + listClustersFails: tt.getClustersFails, + listClustersResp: tt.getClustersResp, + } + + exists, err := ClusterExists(context.Background(), client, testProjectId, testClusterName) + + if tt.isValid && err != nil { + t.Errorf("failed on valid input") + } + if !tt.isValid && err == nil { + t.Errorf("did not fail on invalid input") + } + if !tt.isValid { + return + } + if exists != tt.expectedExists { + t.Errorf("expected exists to be %t, got %t", tt.expectedExists, exists) + } + }) + } +} + +func fixtureProviderOptions(mods ...func(*ske.ProviderOptions)) *ske.ProviderOptions { + providerOptions := &ske.ProviderOptions{ + KubernetesVersions: &[]ske.KubernetesVersion{ + { + State: utils.Ptr("supported"), + Version: utils.Ptr("1.2.3"), + }, + { + State: utils.Ptr("supported"), + Version: utils.Ptr("3.2.1"), + }, + { + State: utils.Ptr("not-supported"), + Version: utils.Ptr("4.4.4"), + }, + }, + MachineImages: &[]ske.MachineImage{ + { + Name: utils.Ptr("flatcar"), + Versions: &[]ske.MachineImageVersion{ + { + State: utils.Ptr("supported"), + Version: utils.Ptr("1.2.3"), + Cri: &[]ske.CRI{ + { + Name: utils.Ptr("not-containerd"), + }, + { + Name: utils.Ptr("containerd"), + }, + }, + }, + { + State: utils.Ptr("supported"), + Version: utils.Ptr("3.2.1"), + Cri: &[]ske.CRI{ + { + Name: utils.Ptr("not-containerd"), + }, + { + Name: utils.Ptr("containerd"), + }, + }, + }, + }, + }, + { + Name: utils.Ptr("not-flatcar"), + Versions: &[]ske.MachineImageVersion{ + { + State: utils.Ptr("supported"), + Version: utils.Ptr("4.4.4"), + Cri: &[]ske.CRI{ + { + Name: utils.Ptr("containerd"), + }, + }, + }, + }, + }, + { + Name: utils.Ptr("flatcar"), + Versions: &[]ske.MachineImageVersion{ + { + State: utils.Ptr("supported"), + Version: utils.Ptr("4.4.4"), + }, + }, + }, + { + Name: utils.Ptr("flatcar"), + Versions: &[]ske.MachineImageVersion{ + { + State: utils.Ptr("not-supported"), + Version: utils.Ptr("4.4.4"), + Cri: &[]ske.CRI{ + { + Name: utils.Ptr("containerd"), + }, + }, + }, + }, + }, + { + Name: utils.Ptr("flatcar"), + Versions: &[]ske.MachineImageVersion{ + { + State: utils.Ptr("supported"), + Version: utils.Ptr("4.4.4"), + Cri: &[]ske.CRI{ + { + Name: utils.Ptr("not-containerd"), + }, + }, + }, + }, + }, + }, + } + for _, mod := range mods { + mod(providerOptions) + } + return providerOptions +} + +func fixtureGetDefaultPayload(mods ...func(*ske.CreateOrUpdateClusterPayload)) *ske.CreateOrUpdateClusterPayload { + payload := &ske.CreateOrUpdateClusterPayload{ + Extensions: &ske.Extension{ + Acl: &ske.ACL{ + AllowedCidrs: &[]string{}, + Enabled: utils.Ptr(false), + }, + }, + Kubernetes: &ske.Kubernetes{ + Version: utils.Ptr("3.2.1"), + }, + Nodepools: &[]ske.Nodepool{ + { + AvailabilityZones: &[]string{ + "eu01-3", + }, + Cri: &ske.CRI{ + Name: utils.Ptr("containerd"), + }, + Machine: &ske.Machine{ + Type: utils.Ptr("b1.2"), + Image: &ske.Image{ + Version: utils.Ptr("3.2.1"), + Name: utils.Ptr("flatcar"), + }, + }, + MaxSurge: utils.Ptr(int64(1)), + Maximum: utils.Ptr(int64(2)), + Minimum: utils.Ptr(int64(1)), + Name: utils.Ptr("pool-default"), + Volume: &ske.Volume{ + Type: utils.Ptr("storage_premium_perf2"), + Size: utils.Ptr(int64(50)), + }, + }, + }, + } + for _, mod := range mods { + mod(payload) + } + return payload +} + +func TestGetDefaultPayload(t *testing.T) { + tests := []struct { + description string + listProviderOptionsFails bool + listProviderOptionsResp *ske.ProviderOptions + isValid bool + expectedOutput *ske.CreateOrUpdateClusterPayload + }{ + { + description: "base", + listProviderOptionsResp: fixtureProviderOptions(), + isValid: true, + expectedOutput: fixtureGetDefaultPayload(), + }, + { + description: "get provider options fails", + listProviderOptionsFails: true, + isValid: false, + }, + { + description: "no Kubernetes versions 1", + listProviderOptionsResp: fixtureProviderOptions(func(po *ske.ProviderOptions) { + po.KubernetesVersions = nil + }), + isValid: false, + }, + { + description: "no Kubernetes versions 2", + listProviderOptionsResp: fixtureProviderOptions(func(po *ske.ProviderOptions) { + po.KubernetesVersions = &[]ske.KubernetesVersion{} + }), + isValid: false, + }, + { + description: "no supported Kubernetes versions", + listProviderOptionsResp: fixtureProviderOptions(func(po *ske.ProviderOptions) { + po.KubernetesVersions = &[]ske.KubernetesVersion{ + { + State: utils.Ptr("not-supported"), + Version: utils.Ptr("1.2.3"), + }, + } + }), + isValid: false, + }, + { + description: "no machine images 1", + listProviderOptionsResp: fixtureProviderOptions(func(po *ske.ProviderOptions) { + po.MachineImages = &[]ske.MachineImage{} + }), + isValid: false, + }, + { + description: "no machine images 2", + listProviderOptionsResp: fixtureProviderOptions(func(po *ske.ProviderOptions) { + po.MachineImages = nil + }), + isValid: false, + }, + { + description: "no machine image versions 1", + listProviderOptionsResp: fixtureProviderOptions(func(po *ske.ProviderOptions) { + po.MachineImages = &[]ske.MachineImage{ + { + Name: utils.Ptr("image-1"), + Versions: nil, + }, + } + }), + isValid: false, + }, + { + description: "no machine image versions 2", + listProviderOptionsResp: fixtureProviderOptions(func(po *ske.ProviderOptions) { + po.MachineImages = &[]ske.MachineImage{ + { + Name: utils.Ptr("image-1"), + Versions: &[]ske.MachineImageVersion{}, + }, + } + }), + isValid: false, + }, + { + description: "no supported machine image versions", + listProviderOptionsResp: fixtureProviderOptions(func(po *ske.ProviderOptions) { + po.MachineImages = &[]ske.MachineImage{ + { + Name: utils.Ptr("image-1"), + Versions: &[]ske.MachineImageVersion{ + { + State: utils.Ptr("not-supported"), + Version: utils.Ptr("1.2.3"), + }, + }, + }, + } + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := &skeClientMocked{ + listProviderOptionsFails: tt.listProviderOptionsFails, + listProviderOptionsResp: tt.listProviderOptionsResp, + } + + output, err := GetDefaultPayload(context.Background(), client) + + if tt.isValid && err != nil { + t.Errorf("failed on valid input") + } + if !tt.isValid && err == nil { + t.Errorf("did not fail on invalid input") + } + if !tt.isValid { + return + } + diff := cmp.Diff(output, tt.expectedOutput) + if diff != "" { + t.Fatalf("Output is not as expected: %s", diff) + } + }) + } +} diff --git a/internal/pkg/spinner/spinner.go b/internal/pkg/spinner/spinner.go new file mode 100644 index 00000000..8035fac2 --- /dev/null +++ b/internal/pkg/spinner/spinner.go @@ -0,0 +1,51 @@ +package spinner + +import ( + "time" + + "github.com/spf13/cobra" +) + +type Spinner struct { + cmd *cobra.Command + message string + states []string + startTime time.Time + delay time.Duration + done chan bool +} + +func New(cmd *cobra.Command) *Spinner { + return &Spinner{ + cmd: cmd, + states: []string{"|", "/", "-", "\\"}, + startTime: time.Now(), + delay: 100 * time.Millisecond, + done: make(chan bool), + } +} + +func (s *Spinner) Start(message string) { + s.message = message + go s.animate() +} + +func (s *Spinner) Stop() { + s.done <- true + close(s.done) + s.cmd.Printf("\r%s ✓ \n", s.message) +} + +func (s *Spinner) animate() { + i := 0 + for { + select { + case <-s.done: + return + default: + s.cmd.Printf("\r%s %s ", s.message, s.states[i%len(s.states)]) + i++ + time.Sleep(s.delay) + } + } +} diff --git a/internal/pkg/tables/tables.go b/internal/pkg/tables/tables.go new file mode 100644 index 00000000..a149fed3 --- /dev/null +++ b/internal/pkg/tables/tables.go @@ -0,0 +1,61 @@ +package tables + +import ( + "fmt" + "stackit/internal/pkg/pager" + + "github.com/jedib0t/go-pretty/v6/table" + "github.com/spf13/cobra" +) + +type Table struct { + table table.Writer +} + +// Creates a new table +func NewTable() Table { + t := table.NewWriter() + return Table{ + table: t, + } +} + +// Sets the header of the table +func (t *Table) SetHeader(header ...interface{}) { + t.table.AppendHeader(table.Row(header)) +} + +// Adds a row to the table +func (t *Table) AddRow(row ...interface{}) { + t.table.AppendRow(table.Row(row)) +} + +// Adds a separator between rows +func (t *Table) AddSeparator() { + t.table.AppendSeparator() +} + +// Enables auto-merging of cells with similar values in the given columns +func (t *Table) EnableAutoMergeOnColumns(columns ...int) { + var colConfigs []table.ColumnConfig + for _, c := range columns { + colConfigs = append(colConfigs, table.ColumnConfig{Number: c, AutoMerge: true}) + } + t.table.SetColumnConfigs(colConfigs) +} + +// Returns the table rendered +func (t *Table) Render() string { + t.table.SetStyle(table.StyleLight) + t.table.Style().Options.DrawBorder = false + t.table.Style().Options.SeparateRows = false + t.table.Style().Options.SeparateColumns = true + t.table.Style().Options.SeparateHeader = true + + return fmt.Sprintf("\n%s\n\n", t.table.Render()) +} + +// Displays the table in the command's stdout +func (t *Table) Display(cmd *cobra.Command) error { + return pager.Display(cmd, t.Render()) +} diff --git a/internal/pkg/utils/utils.go b/internal/pkg/utils/utils.go new file mode 100644 index 00000000..51678c65 --- /dev/null +++ b/internal/pkg/utils/utils.go @@ -0,0 +1,28 @@ +package utils + +import ( + "fmt" + + "github.com/google/uuid" + "github.com/spf13/cobra" +) + +// Ptr Returns the pointer to any type T +func Ptr[T any](v T) *T { + return &v +} + +// CmdHelp is used to explicitly set the Run function for non-leaf commands to the command help function, so that we can catch invalid commands +// This is a workaround needed due to the open issue on the Cobra repo: https://github.com/spf13/cobra/issues/706 +func CmdHelp(cmd *cobra.Command, _ []string) { + cmd.Help() //nolint:errcheck //the function doesnt return anything to satisfy the required interface of the Run function +} + +// ValidateUUID validates if the provided string is a valid UUID +func ValidateUUID(value string) error { + _, err := uuid.Parse(value) + if err != nil { + return fmt.Errorf("parse %s as UUID: %w", value, err) + } + return nil +} diff --git a/main.go b/main.go new file mode 100644 index 00000000..1a855ccc --- /dev/null +++ b/main.go @@ -0,0 +1,19 @@ +package main + +import ( + "stackit/internal/cmd" + "stackit/internal/pkg/config" +) + +// These values are dynamically overridden by GoReleaser +var ( + version = "DEV" + date = "UNKNOWN" +) + +func main() { + // Set up configuration files + config.InitConfig() + + cmd.Execute(version, date) +} diff --git a/renovate.json b/renovate.json new file mode 100644 index 00000000..7190a60b --- /dev/null +++ b/renovate.json @@ -0,0 +1,3 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json" +} diff --git a/scripts/generate.go b/scripts/generate.go new file mode 100644 index 00000000..a44f9c26 --- /dev/null +++ b/scripts/generate.go @@ -0,0 +1,52 @@ +package main + +import ( + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "stackit/internal/cmd" + "strings" + + "github.com/spf13/cobra/doc" +) + +const ( + DocsFolder = "docs" +) + +func main() { + repoRoot, err := getGitRepoRoot() + if err != nil { + log.Fatalf("Error determining Git repository root: %v", err) + } + docsDir := filepath.Join(repoRoot, DocsFolder) + err = os.RemoveAll(docsDir) + if err != nil { + log.Fatalf("Error removing old documentation directory: %v", err) + } + err = os.Mkdir(docsDir, os.ModePerm) + if err != nil { + log.Fatalf("Error creating new documentation directory: %v", err) + } + + filePrepender := func(filename string) string { + return "" + } + linkHandler := func(filename string) string { + return fmt.Sprintf("./%s", filename) + } + err = doc.GenMarkdownTreeCustom(cmd.NewRootCmd("", ""), docsDir, filePrepender, linkHandler) + if err != nil { + log.Fatalf("Error generating documentation: %v", err) + } +} + +func getGitRepoRoot() (string, error) { + output, err := exec.Command("git", "rev-parse", "--show-toplevel").Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(output)), nil +} diff --git a/scripts/project.sh b/scripts/project.sh new file mode 100755 index 00000000..fd15c32e --- /dev/null +++ b/scripts/project.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +# This script is used to manage the project, only used for installing the required tools for now +# Usage: ./project.sh [action] +# * tools: Install required tools to run the project +set -eo pipefail + +ROOT_DIR=$(git rev-parse --show-toplevel) + +action=$1 + +if [ "$action" = "help" ]; then + [ -f "$0".man ] && man "$0".man || echo "No help, please read the script in ${script}, we will add help later" +elif [ "$action" = "tools" ]; then + cd ${ROOT_DIR} + go mod download + go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.52.2 +else + echo "Invalid action: '$action', please use $0 help for help" +fi